Skip to content

Commit d780944

Browse files
authored
add logincreds provider (#3230)
1 parent 115ff14 commit d780944

File tree

26 files changed

+918
-1
lines changed

26 files changed

+918
-1
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"id": "c38752ce-4b6e-436d-ada2-7328145e3f4f",
3+
"type": "feature",
4+
"description": "Add support for AWS Login credentials (package credentials/logincreds) to the default credential chain.",
5+
"modules": [
6+
".",
7+
"config",
8+
"credentials"
9+
]
10+
}

aws/credentials.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ const (
118118
CredentialSourceHTTP
119119
// CredentialSourceIMDS credentials resolved from the instance metadata service (IMDS)
120120
CredentialSourceIMDS
121+
// CredentialSourceProfileLogin credentials resolved from an `aws login` session sourced from a profile
122+
CredentialSourceProfileLogin
123+
// CredentialSourceLogin credentials resolved from an `aws login` session
124+
CredentialSourceLogin
121125
)
122126

123127
// A Credentials is the AWS credentials value for individual credential fields.

aws/middleware/user_agent.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ const (
137137
UserAgentFeatureCredentialsIMDS = "0"
138138

139139
UserAgentFeatureBearerServiceEnvVars = "3"
140+
141+
UserAgentFeatureCredentialsProfileLogin = "AC"
142+
UserAgentFeatureCredentialsLogin = "AD"
140143
)
141144

142145
var credentialSourceToFeature = map[aws.CredentialSource]UserAgentFeature{
@@ -160,6 +163,8 @@ var credentialSourceToFeature = map[aws.CredentialSource]UserAgentFeature{
160163
aws.CredentialSourceProcess: UserAgentFeatureCredentialsProcess,
161164
aws.CredentialSourceHTTP: UserAgentFeatureCredentialsHTTP,
162165
aws.CredentialSourceIMDS: UserAgentFeatureCredentialsIMDS,
166+
aws.CredentialSourceProfileLogin: UserAgentFeatureCredentialsProfileLogin,
167+
aws.CredentialSourceLogin: UserAgentFeatureCredentialsLogin,
163168
}
164169

165170
// RequestUserAgent is a build middleware that set the User-Agent for the request.

config/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/aws/aws-sdk-go-v2/credentials v1.18.25
88
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13
99
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
10+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.0
1011
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3
1112
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7
1213
github.com/aws/aws-sdk-go-v2/service/sts v1.41.0
@@ -36,6 +37,8 @@ replace github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding => ../serv
3637

3738
replace github.com/aws/aws-sdk-go-v2/service/internal/presigned-url => ../service/internal/presigned-url/
3839

40+
replace github.com/aws/aws-sdk-go-v2/service/signin => ../service/signin/
41+
3942
replace github.com/aws/aws-sdk-go-v2/service/sso => ../service/sso/
4043

4144
replace github.com/aws/aws-sdk-go-v2/service/ssooidc => ../service/ssooidc/

config/resolve_credentials.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import (
1313
"github.com/aws/aws-sdk-go-v2/credentials"
1414
"github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds"
1515
"github.com/aws/aws-sdk-go-v2/credentials/endpointcreds"
16+
"github.com/aws/aws-sdk-go-v2/credentials/logincreds"
1617
"github.com/aws/aws-sdk-go-v2/credentials/processcreds"
1718
"github.com/aws/aws-sdk-go-v2/credentials/ssocreds"
1819
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
1920
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
21+
"github.com/aws/aws-sdk-go-v2/service/signin"
2022
"github.com/aws/aws-sdk-go-v2/service/sso"
2123
"github.com/aws/aws-sdk-go-v2/service/ssooidc"
2224
"github.com/aws/aws-sdk-go-v2/service/sts"
@@ -172,7 +174,10 @@ func resolveCredsFromProfile(ctx context.Context, cfg *aws.Config, envConfig *En
172174
ctx = addCredentialSource(ctx, aws.CredentialSourceProfileSSO)
173175
}
174176
err = resolveSSOCredentials(ctx, cfg, sharedConfig, configs)
175-
177+
case len(sharedConfig.LoginSession) > 0:
178+
ctx = addCredentialSource(ctx, aws.CredentialSourceProfileLogin)
179+
ctx = addCredentialSource(ctx, aws.CredentialSourceLogin)
180+
err = resolveLoginCredentials(ctx, cfg, sharedConfig, configs)
176181
case len(sharedConfig.CredentialProcess) != 0:
177182
// Get credentials from CredentialProcess
178183
ctx = addCredentialSource(ctx, aws.CredentialSourceProfileProcess)
@@ -625,3 +630,21 @@ func addCredentialSource(ctx context.Context, source aws.CredentialSource) conte
625630
func getCredentialSources(ctx context.Context) []aws.CredentialSource {
626631
return ctx.Value(credentialSource{}).([]aws.CredentialSource)
627632
}
633+
634+
func resolveLoginCredentials(ctx context.Context, cfg *aws.Config, sharedCfg *SharedConfig, configs configs) error {
635+
cacheDir := os.Getenv("AWS_LOGIN_CACHE_DIRECTORY")
636+
tokenPath, err := logincreds.StandardCachedTokenFilepath(sharedCfg.LoginSession, cacheDir)
637+
if err != nil {
638+
return err
639+
}
640+
641+
svc := signin.NewFromConfig(*cfg)
642+
provider := logincreds.New(svc, tokenPath, func(o *logincreds.Options) {
643+
o.CredentialSources = getCredentialSources(ctx)
644+
})
645+
cfg.Credentials, err = wrapWithCredentialsCache(ctx, configs, provider)
646+
if err != nil {
647+
return err
648+
}
649+
return nil
650+
}

config/shared_config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ const (
125125
checksumWhenRequired = "when_required"
126126

127127
authSchemePreferenceKey = "auth_scheme_preference"
128+
129+
loginSessionKey = "login_session"
128130
)
129131

130132
// defaultSharedConfigProfile allows for swapping the default profile for testing
@@ -362,6 +364,9 @@ type SharedConfig struct {
362364

363365
// Priority list of preferred auth scheme names (e.g. sigv4a).
364366
AuthSchemePreference []string
367+
368+
// Session ARN from an `aws login` session.
369+
LoginSession string
365370
}
366371

367372
func (c SharedConfig) getDefaultsMode(ctx context.Context) (value aws.DefaultsMode, ok bool, err error) {
@@ -897,6 +902,8 @@ func mergeSections(dst *ini.Sections, src ini.Sections) error {
897902
ssoStartURLKey,
898903

899904
authSchemePreferenceKey,
905+
906+
loginSessionKey,
900907
}
901908
for i := range stringKeys {
902909
if err := mergeStringKey(&srcSection, &dstSection, sectionName, stringKeys[i]); err != nil {
@@ -1175,6 +1182,8 @@ func (c *SharedConfig) setFromIniSection(profile string, section ini.Section) er
11751182

11761183
c.AuthSchemePreference = toAuthSchemePreferenceList(section.String(authSchemePreferenceKey))
11771184

1185+
updateString(&c.LoginSession, section, loginSessionKey)
1186+
11781187
return nil
11791188
}
11801189

credentials/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.23
55
require (
66
github.com/aws/aws-sdk-go-v2 v1.39.6
77
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13
8+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.0
89
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3
910
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7
1011
github.com/aws/aws-sdk-go-v2/service/sts v1.41.0
@@ -30,6 +31,8 @@ replace github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding => ../serv
3031

3132
replace github.com/aws/aws-sdk-go-v2/service/internal/presigned-url => ../service/internal/presigned-url/
3233

34+
replace github.com/aws/aws-sdk-go-v2/service/signin => ../service/signin/
35+
3336
replace github.com/aws/aws-sdk-go-v2/service/sso => ../service/sso/
3437

3538
replace github.com/aws/aws-sdk-go-v2/service/ssooidc => ../service/ssooidc/

credentials/logincreds/dpop.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package logincreds
2+
3+
import (
4+
"context"
5+
"crypto/ecdsa"
6+
cryptorand "crypto/rand"
7+
"crypto/sha256"
8+
"crypto/x509"
9+
"encoding/base64"
10+
"encoding/json"
11+
"encoding/pem"
12+
"fmt"
13+
14+
"github.com/aws/aws-sdk-go-v2/internal/sdk"
15+
"github.com/aws/aws-sdk-go-v2/service/signin"
16+
"github.com/aws/smithy-go/middleware"
17+
smithyrand "github.com/aws/smithy-go/rand"
18+
smithyhttp "github.com/aws/smithy-go/transport/http"
19+
)
20+
21+
// AWS signin DPOP always uses the P256 curve
22+
const curvelen = 256 / 8 // bytes
23+
24+
// https://datatracker.ietf.org/doc/html/rfc9449
25+
func mkdpop(token *loginToken, htu string) (string, error) {
26+
key, err := parseKey(token.DPOPKey)
27+
if err != nil {
28+
return "", fmt.Errorf("parse key: %w", err)
29+
}
30+
31+
header, err := jsonb64(&dpopHeader{
32+
Typ: "dpop+jwt",
33+
Alg: "ES256",
34+
Jwk: &dpopHeaderJwk{
35+
Kty: "EC",
36+
X: base64.RawURLEncoding.EncodeToString(key.X.Bytes()),
37+
Y: base64.RawURLEncoding.EncodeToString(key.Y.Bytes()),
38+
Crv: "P-256",
39+
},
40+
})
41+
if err != nil {
42+
return "", fmt.Errorf("marshal header: %w", err)
43+
}
44+
45+
uuid, err := smithyrand.NewUUID(cryptorand.Reader).GetUUID()
46+
if err != nil {
47+
return "", fmt.Errorf("uuid: %w", err)
48+
}
49+
50+
payload, err := jsonb64(&dpopPayload{
51+
Jti: uuid,
52+
Htm: "POST",
53+
Htu: htu,
54+
Iat: sdk.NowTime().Unix(),
55+
})
56+
if err != nil {
57+
return "", fmt.Errorf("marshal payload: %w", err)
58+
}
59+
60+
msg := fmt.Sprintf("%s.%s", header, payload)
61+
62+
h := sha256.New()
63+
h.Write([]byte(msg))
64+
65+
r, s, err := ecdsa.Sign(cryptorand.Reader, key, h.Sum(nil))
66+
if err != nil {
67+
return "", fmt.Errorf("sign: %w", err)
68+
}
69+
70+
// DPOP signatures are formatted in RAW r || s form (with each value padded
71+
// to fit in curve size which in our case is always the 256 bits) - rather
72+
// than encoded in something like asn.1
73+
sig := make([]byte, curvelen*2)
74+
r.FillBytes(sig[0:curvelen])
75+
s.FillBytes(sig[curvelen:])
76+
77+
dpop := fmt.Sprintf("%s.%s", msg, base64.RawURLEncoding.EncodeToString(sig))
78+
return dpop, nil
79+
}
80+
81+
func parseKey(pemBlock string) (*ecdsa.PrivateKey, error) {
82+
block, _ := pem.Decode([]byte(pemBlock))
83+
priv, err := x509.ParseECPrivateKey(block.Bytes)
84+
if err != nil {
85+
return nil, fmt.Errorf("parse ec private key: %w", err)
86+
}
87+
88+
return priv, nil
89+
}
90+
91+
func jsonb64(v any) (string, error) {
92+
j, err := json.MarshalIndent(v, "", " ")
93+
if err != nil {
94+
return "", err
95+
}
96+
97+
return base64.RawURLEncoding.EncodeToString(j), nil
98+
}
99+
100+
type dpopHeader struct {
101+
Typ string `json:"typ"`
102+
Alg string `json:"alg"`
103+
Jwk *dpopHeaderJwk `json:"jwk"`
104+
}
105+
106+
type dpopHeaderJwk struct {
107+
Kty string `json:"kty"`
108+
X string `json:"x"`
109+
Y string `json:"y"`
110+
Crv string `json:"crv"`
111+
}
112+
113+
type dpopPayload struct {
114+
Jti string `json:"jti"`
115+
Htm string `json:"htm"`
116+
Htu string `json:"htu"`
117+
Iat int64 `json:"iat"`
118+
}
119+
120+
type signDPOP struct {
121+
Token *loginToken
122+
}
123+
124+
func addSignDPOP(token *loginToken) func(o *signin.Options) {
125+
return signin.WithAPIOptions(func(stack *middleware.Stack) error {
126+
return stack.Finalize.Add(&signDPOP{token}, middleware.After)
127+
})
128+
}
129+
130+
func (*signDPOP) ID() string {
131+
return "signDPOP"
132+
}
133+
134+
func (m *signDPOP) HandleFinalize(
135+
ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
136+
out middleware.FinalizeOutput, md middleware.Metadata, err error,
137+
) {
138+
req, ok := in.Request.(*smithyhttp.Request)
139+
if !ok {
140+
return out, md, fmt.Errorf("unexpected transport type %T", req)
141+
}
142+
143+
dpop, err := mkdpop(m.Token, req.URL.String())
144+
if err != nil {
145+
return out, md, fmt.Errorf("sign dpop: %w", err)
146+
}
147+
148+
req.Header.Set("DPoP", dpop)
149+
return next.HandleFinalize(ctx, in)
150+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package logincreds
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/sha256"
6+
"encoding/base64"
7+
"fmt"
8+
"math/big"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// the signature is random so the only way to test that is to actually
14+
// cryptographically verify the signature
15+
func TestDPOP(t *testing.T) {
16+
token, key := mockToken()
17+
dpop, err := mkdpop(token, "htu")
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
22+
parts := strings.Split(dpop, ".")
23+
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
28+
msg := fmt.Sprintf("%s.%s", parts[0], parts[1])
29+
h := sha256.New()
30+
h.Write([]byte(msg))
31+
32+
r := new(big.Int).SetBytes(sig[0:curvelen])
33+
s := new(big.Int).SetBytes(sig[curvelen:])
34+
if !ecdsa.Verify(&key.PublicKey, h.Sum(nil), r, s) {
35+
t.Error("ecdsa signature verify failed")
36+
}
37+
}

credentials/logincreds/file.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package logincreds
2+
3+
import (
4+
"io"
5+
"os"
6+
)
7+
8+
var openFile func(string) (io.ReadCloser, error) = func(name string) (io.ReadCloser, error) {
9+
return os.Open(name)
10+
}
11+
12+
var createFile func(string) (io.WriteCloser, error) = func(name string) (io.WriteCloser, error) {
13+
return os.Create(name)
14+
}

0 commit comments

Comments
 (0)