Skip to content

Implement OAuth2 PKCE in SSORoleCredentialsProvider #1258

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
module github.com/99designs/aws-vault/v7

go 1.20
go 1.21

toolchain go1.23.1

require (
github.com/99designs/keyring v1.2.2
github.com/alecthomas/kingpin/v2 v2.3.2
github.com/aws/aws-sdk-go-v2 v1.17.7
github.com/aws/aws-sdk-go-v2/config v1.18.19
github.com/aws/aws-sdk-go-v2/credentials v1.13.18
github.com/aws/aws-sdk-go-v2/service/iam v1.19.8
github.com/aws/aws-sdk-go-v2/service/sso v1.12.6
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6
github.com/aws/aws-sdk-go-v2/service/sts v1.18.7
github.com/aws/aws-sdk-go-v2 v1.32.5
github.com/aws/aws-sdk-go-v2/config v1.28.5
github.com/aws/aws-sdk-go-v2/credentials v1.17.46
github.com/aws/aws-sdk-go-v2/service/iam v1.38.1
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5
github.com/aws/aws-sdk-go-v2/service/sts v1.33.1
github.com/google/go-cmp v0.5.9
github.com/mattn/go-isatty v0.0.18
github.com/mattn/go-tty v0.0.4
Expand All @@ -23,16 +25,18 @@ require (
require (
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
Expand Down
29 changes: 29 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,58 @@ github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAu
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/aws/aws-sdk-go-v2 v1.17.7 h1:CLSjnhJSTSogvqUGhIC6LqFKATMRexcxLZ0i/Nzk9Eg=
github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo=
github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/config v1.18.19 h1:AqFK6zFNtq4i1EYu+eC7lcKHYnZagMn6SW171la0bGw=
github.com/aws/aws-sdk-go-v2/config v1.18.19/go.mod h1:XvTmGMY8d52ougvakOv1RpiTLPz9dlG/OQHsKU/cMmY=
github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o=
github.com/aws/aws-sdk-go-v2/credentials v1.13.18 h1:EQMdtHwz0ILTW1hoP+EwuWhwCG1hD6l3+RWFQABET4c=
github.com/aws/aws-sdk-go-v2/credentials v1.13.18/go.mod h1:vnwlwjIe+3XJPBYKu1et30ZPABG3VaXJYr8ryohpIyM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 h1:gt57MN3liKiyGopcqgNzJb2+d9MJaKT/q1OksHNXVE4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1/go.mod h1:lfUx8puBRdM5lVVMQlwt2v+ofiG/X6Ms+dy0UkG/kXw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 h1:sJLYcS+eZn5EeNINGHSCRAwUJMFVqklwkH36Vbyai7M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31/go.mod h1:QT0BqUvX1Bh2ABdTGnjqEjvjzrCfIniM9Sc8zn9Yndo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 h1:1mnRASEKnkqsntcxHaysxwgVoUUp5dkiB+l3llKnqyg=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25/go.mod h1:zBHOPwhBc3FlQjQJE/D3IfPWiWaQmT06Vq9aNukDo0k=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 h1:p5luUImdIqywn6JpQsW3tq5GNOxKmOnEpybzPx+d1lk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32/go.mod h1:XGhIBZDEgfqmFIugclZ6FU7v75nHhBDtzuB4xB/tEi4=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/iam v1.19.8 h1:kQsBeGgm68kT0xc90spgC5qEOQGH74V2bFqgBgG21Bo=
github.com/aws/aws-sdk-go-v2/service/iam v1.19.8/go.mod h1:lf/oAjt//UvPsmnOgPT61F+q4K6U0q4zDd1s1yx2NZs=
github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 h1:hfkzDZHBp9jAT4zcd5mtqckpU4E3Ax0LQaEWWk1VgN8=
github.com/aws/aws-sdk-go-v2/service/iam v1.38.1/go.mod h1:u36ahDtZcQHGmVm/r+0L1sfKX4fzLEMdCqiKRKkUMVM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 h1:5LHn8JQ0qvjD9L9JhMtylnkcw7j05GDZqM9Oin6hpr0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25/go.mod h1:/95IA+0lMnzW6XzqYJRpjjsAbKEORVeO0anQqjd2CNU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E=
github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 h1:5V7DWLBd7wTELVz5bPpwzYy/sikk0gsgZfj40X+l5OI=
github.com/aws/aws-sdk-go-v2/service/sso v1.12.6/go.mod h1:Y1VOmit/Fn6Tz1uFAeCO6Q7M2fmfXSCLeL5INVYsLuY=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 h1:B8cauxOH1W1v7rd8RdI/MWnoR4Ze0wIHWrb90qczxj4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6/go.mod h1:Lh/bc9XUf8CfOY6Jp5aIkQtN+j1mc+nExc+KXj9jx2s=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 h1:bWNgNdRko2x6gqa0blfATqAZKZokPIeM1vfmQt2pnvM=
github.com/aws/aws-sdk-go-v2/service/sts v1.18.7/go.mod h1:JuTnSoeePXmMVe9G8NcjjwgOKEfZ4cOjMuT2IBT/2eI=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg=
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -45,6 +73,7 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
Expand Down
220 changes: 209 additions & 11 deletions vault/ssorolecredentialsprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ package vault

import (
"context"
crand "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"time"

Expand Down Expand Up @@ -122,7 +129,19 @@ func (p *SSORoleCredentialsProvider) getOIDCToken(ctx context.Context) (token *s
return token, true, nil
}
}
token, err = p.newOIDCToken(ctx)

// if we must use stdout (either by user choice or because we have determined we are in an SSH session where we cannot open a browser)
// then we use the "device code" grant flow, and print URLs to stdout for the user to manually copy/paste into their browser.
//
// Otherwise we use the "authorization code" grant flow with Proof Key for
// Code Exchange (PKCE) and open the browser automatically. The latter flow
// is more user-friendly and secure because the step comparing the challenge
// code between the CLI and browser is automated entirely.
if p.UseStdout {
Copy link
Contributor

Choose a reason for hiding this comment

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

I commonly use --stdout even when I'm on the same machine as where aws-vault is running, for various purposes (e.g. wanting to login in a non-default browser). I'm not 100% convinced that UseStdout should be the basis for chosing between the device code and PKCE flow, how about instead following the same behavior as the AWS CLI? It seems to have a flag --use-device-code flag aws/aws-cli@130005a#diff-e07a10a6eb1a677e905b0498651e672137b5dff19d357933bd7bc3e36f845a3bL42 which defaults to false

Copy link
Author

Choose a reason for hiding this comment

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

Great shout. I hadn't considered the same device non-default browser path that users might wish to take. I was primarily thinking about remote workstations and in that context printing the URL out to be opened locally would not make sense as the eventual redirect destination would be inaccessible to the browser.

token, err = p.newOIDCToken(ctx)
} else {
token, err = p.newOIDCTokenPKCE(ctx)
}
if err != nil {
return nil, false, err
}
Expand Down Expand Up @@ -155,16 +174,7 @@ func (p *SSORoleCredentialsProvider) newOIDCToken(ctx context.Context) (*ssooidc
return nil, err
}
log.Printf("Created OIDC device code for %s (expires in: %ds)", p.StartURL, deviceCreds.ExpiresIn)

if p.UseStdout {
fmt.Fprintf(os.Stderr, "Open the SSO authorization page in a browser (use Ctrl-C to abort)\n%s\n", aws.ToString(deviceCreds.VerificationUriComplete))
} else {
log.Println("Opening SSO authorization page in browser")
fmt.Fprintf(os.Stderr, "Opening the SSO authorization page in your default browser (use Ctrl-C to abort)\n%s\n", aws.ToString(deviceCreds.VerificationUriComplete))
if err := open.Run(aws.ToString(deviceCreds.VerificationUriComplete)); err != nil {
log.Printf("Failed to open browser: %s", err)
}
}
fmt.Fprintf(os.Stderr, "Open the SSO authorization page in a browser (use Ctrl-C to abort)\n%s\n", aws.ToString(deviceCreds.VerificationUriComplete))

// These are the default values defined in the following RFC:
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.5
Expand Down Expand Up @@ -201,3 +211,191 @@ func (p *SSORoleCredentialsProvider) newOIDCToken(ctx context.Context) (*ssooidc
return t, nil
}
}

// newOIDCTokenPKCE generates a new OIDC token using the "Authorization Code Grant" flow with PKCE.
func (p *SSORoleCredentialsProvider) newOIDCTokenPKCE(ctx context.Context) (*ssooidc.CreateTokenOutput, error) {
// ref: https://datatracker.ietf.org/doc/html/rfc7636

// generate a random 32 byte code verifier; base64 encode it
codeVerifierBytes := make([]byte, 32)
n, err := crand.Read(codeVerifierBytes)
if err != nil || n != 32 {
return nil, fmt.Errorf("failed to generate PKCE verifier: %w", err)
}
codeVerifier := base64.RawURLEncoding.EncodeToString(codeVerifierBytes)

// generate the code challenge: base64(sha256(codeVerifier))
codeChallengeBytes := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(codeChallengeBytes[:])
log.Printf("Generated PKCE code_challenge: %q", codeChallenge)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this necessarily needs to be displayed by default

Copy link
Author

Choose a reason for hiding this comment

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

I was confused at first also at the use of log.Printf in this project but these log lines are not displayed unless the user passes --debug as they're otherwise sent to io.Discard

aws-vault/cli/global.go

Lines 166 to 168 in d4706c8

if !a.Debug {
log.SetOutput(io.Discard)
}

Copy link
Contributor

Choose a reason for hiding this comment

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

ah, good point


clientCreds, err := p.OIDCClient.RegisterClient(ctx, &ssooidc.RegisterClientInput{
ClientName: aws.String("aws-vault"),
ClientType: aws.String("public"),
GrantTypes: []string{"authorization_code", "refresh_token"},
Scopes: []string{"sso:account:access"},
IssuerUrl: aws.String(p.StartURL),
RedirectUris: []string{"http://127.0.0.1/oauth/callback"},
})
if err != nil {
return nil, err
}
log.Printf("Created new OIDC client (expires at: %s)", time.Unix(clientCreds.ClientSecretExpiresAt, 0))

// start the callback server
cbServer, err := newOauthCallbackServer()
if err != nil {
return nil, fmt.Errorf("failed to create oauthCallbackServer: %w", err)
}
log.Printf("oauthCallbackServer callback endpoint: %s", cbServer.redirectURI())
go func() {
if err := cbServer.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {
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)
redirectURI := cbServer.redirectURI()

// construct the authorize URL with the client and PKCE parameters
args := url.Values{
"client_id": {aws.ToString(clientCreds.ClientId)},
"response_type": {"code"},
"redirect_uri": {redirectURI},
"state": {cbServer.state},
"code_challenge_method": {"S256"},
"code_challenge": {codeChallenge},
"scopes": {"sso:account:access"},
}
// prefer the base endpoint from client options, otherwise use a default
var host string
if p.OIDCClient.Options().BaseEndpoint != nil && *p.OIDCClient.Options().BaseEndpoint != "" {
host = *p.OIDCClient.Options().BaseEndpoint
} else {
host = "oidc.us-east-1.amazonaws.com"
}

Choose a reason for hiding this comment

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

Hardcoding the region to us-east-1 won't work in all use cases. For example our SSO region is eu-west-1.

To get this to work I used host = fmt.Sprintf("oidc.%s.amazonaws.com", p.OIDCClient.Options().Region)

authorizeURL := url.URL{
Scheme: "https",
Host: host,
Path: "/authorize",
RawQuery: args.Encode(),
}
log.Printf("Authorize URL: %s", authorizeURL.String())

// redirect user to the authorize URL
log.Println("Opening SSO authorization page in browser")
Copy link
Contributor

Choose a reason for hiding this comment

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

(related to my first comment of this PR, I think this should rather honor the --stdout parameter to decide what to do)

fmt.Fprintf(os.Stderr, "Opening the SSO authorization page in your default browser (use Ctrl-C to abort)\n%s\n", authorizeURL.String())
if err := open.Run(authorizeURL.String()); err != nil {
log.Printf("Failed to open browser: %s", err)
}

// await the authorization code from the callback server once the user has completed the flow.
var code string
timeout := time.After(1 * time.Minute)
Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW the device code implementation here waits indefinitely (modulo a timeout on the AWS side), so perhaps it's worth increasing it?

select {
case <-timeout:
return nil, errors.New("timed out waiting for authorization code")
case code = <-cbServer.code:
log.Printf("Received authorization code: %s", code)
if err := cbServer.h.Close(); err != nil {
log.Printf("Failed to close oauthCallbackServer: %s", err)
}
}

// create the OIDC token using the authorization code received from the callback server
tok, err := p.OIDCClient.CreateToken(ctx, &ssooidc.CreateTokenInput{
ClientId: clientCreds.ClientId,
ClientSecret: clientCreds.ClientSecret,
Code: aws.String(code),
CodeVerifier: aws.String(codeVerifier),
GrantType: aws.String("authorization_code"),
RedirectUri: aws.String(redirectURI),
})
if err != nil {
return nil, err
}

log.Printf("Created new OIDC access token for %s (expires in: %ds)", p.StartURL, tok.ExpiresIn)
return tok, nil
}

// newOauthCallbackServer creates a HTTP server listening on a random localhost
// port to serve the OAuth2 callback. It serves a single oauth callback endpoint
// 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")
if err != nil {
return nil, fmt.Errorf("failed to create listener: %w", err)
}
log.Printf("oauthCallbackListener listening on %s", ln.Addr().String())

// create a 32 byte state for CSRF protection
state := make([]byte, 32)
n, err := crand.Read(state)
if err != nil || n != 32 {
return nil, fmt.Errorf("failed to generate state: %w", err)
}

oauth := &oauthCallbackServer{
state: base64.RawURLEncoding.EncodeToString(state),
code: make(chan string),
ln: ln,
}
oauth.h = &http.Server{
Handler: http.HandlerFunc(oauth.handleCallback),
}

return oauth, nil
}

// handleCallback handles the OAuth2 callback request and sends the authorization code to the server channel.
func (s *oauthCallbackServer) handleCallback(w http.ResponseWriter, r *http.Request) {
// only respond to GET requests on the callback
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/oauth/callback" {
http.Error(w, "Not Found", http.StatusNotFound)
return
}

// constant time string comparison of want vs got state
state := r.URL.Query().Get("state")
if subtle.ConstantTimeCompare([]byte(state), []byte(s.state)) != 1 {
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}

// send the authorization code to the channel
code := r.URL.Query().Get("code")
s.code <- code
Copy link
Contributor

Choose a reason for hiding this comment

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

perhaps it's worth returning early if there's an error rather than letting the consumer timeout? In which case s.code could be a channel of a struct that can hold either an error or a string, and the consumer would do something like

code, err := <- server.code
if err != nil {
   // error
}


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

// redirectURI returns the URL for the OAuth callback endpoint with the server's port included in the address.
func (s *oauthCallbackServer) redirectURI() string {
// AWS requires that the callback be a 127.0.0.1 v4 address
u := url.URL{
Scheme: "http",
Host: fmt.Sprintf("127.0.0.1:%d", s.ln.Addr().(*net.TCPAddr).Port),
Path: "/oauth/callback",
}
return u.String()
}

type oauthCallbackServer struct {
ln net.Listener
h *http.Server

// secret used to prevent CSRF attacks
state string
// channel to send authorization code after successful callback
code chan string
}

func (s *oauthCallbackServer) Serve() error {
return s.h.Serve(s.ln)
}
9 changes: 8 additions & 1 deletion vault/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,20 @@ func NewAssumeRoleWithWebIdentityProvider(k keyring.Keyring, config *ProfileConf
func NewSSORoleCredentialsProvider(k keyring.Keyring, config *ProfileConfig, useSessionCache bool) (aws.CredentialsProvider, error) {
cfg := NewAwsConfig(config.SSORegion, config.STSRegionalEndpoints)

// If we're in an SSH session, we can't use the browser for SSO so we print
// the URLs to stdout instead.
useStdout := config.SSOUseStdout
if os.Getenv("SSH_CONNECTION") != "" {
Copy link
Contributor

Choose a reason for hiding this comment

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

this seems like an addition that's independant from the PKCE flow? Overall I wouldn't necessarily expect this "magic" behavior from aws-vault, as a user I prefer a consistent behavior and explicitely passing --stdout if needs be

Copy link
Author

Choose a reason for hiding this comment

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

Happy to back this out and have it be a separate discussion. My thinking was that given we now have login flows that cater to browser and non-browser setups to direct the user to the one that makes sense in context but I can understand how that would be confusing. A number of us at Tailscale use aws-vault with remote workspaces and run into the no-browser scenario frequently.

useStdout = true
}

ssoRoleCredentialsProvider := &SSORoleCredentialsProvider{
OIDCClient: ssooidc.NewFromConfig(cfg),
StartURL: config.SSOStartURL,
SSOClient: sso.NewFromConfig(cfg),
AccountID: config.SSOAccountID,
RoleName: config.SSORoleName,
UseStdout: config.SSOUseStdout,
UseStdout: useStdout,
}

if useSessionCache {
Expand Down