Skip to content

Commit e9b2e8d

Browse files
authored
Add AWS auth and error foundations (#108)
* Add AWS auth and error foundations - Adds dependency-free SigV4 parsing, known-key auth modes, explicit missing and unknown auth states, and gateway identity context. - Adds AWS XML, S3 REST XML, and JSON error serializers for SDK-compatible service failures. - Covers header, presigned, relaxed, known-key, strict, gateway, and error serialization behavior in Go tests. * Harden AWS auth resolution * Fix AWS auth and error defaults
1 parent 9a4ca15 commit e9b2e8d

9 files changed

Lines changed: 1014 additions & 35 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package auth
2+
3+
type Credential struct {
4+
AccessKeyID string
5+
SecretAccessKey string
6+
SessionToken string
7+
AccountID string
8+
PrincipalARN string
9+
Disabled bool
10+
}
11+
12+
type Store struct {
13+
credentials map[string]Credential
14+
}
15+
16+
func NewStore(credentials ...Credential) *Store {
17+
store := &Store{credentials: map[string]Credential{}}
18+
for _, credential := range credentials {
19+
if credential.AccessKeyID == "" {
20+
continue
21+
}
22+
store.credentials[credential.AccessKeyID] = credential
23+
}
24+
return store
25+
}
26+
27+
func (store *Store) Resolve(accessKeyID string) (Credential, bool) {
28+
if store == nil || accessKeyID == "" {
29+
return Credential{}, false
30+
}
31+
credential, ok := store.credentials[accessKeyID]
32+
if !ok || credential.Disabled {
33+
return Credential{}, false
34+
}
35+
return credential, true
36+
}

internal/services/aws/auth/mode.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
type Mode string
9+
10+
const (
11+
ModeRelaxed Mode = "relaxed"
12+
ModeKnownKeys Mode = "known-keys"
13+
ModeStrict Mode = "strict"
14+
)
15+
16+
func ParseMode(value string) (Mode, error) {
17+
mode := Mode(strings.TrimSpace(strings.ToLower(value)))
18+
if mode == "" {
19+
return ModeRelaxed, nil
20+
}
21+
if mode.Valid() {
22+
return mode, nil
23+
}
24+
return "", fmt.Errorf("unknown AWS auth mode %q", value)
25+
}
26+
27+
func NormalizeMode(mode Mode) Mode {
28+
if mode == "" {
29+
return ModeRelaxed
30+
}
31+
if mode.Valid() {
32+
return mode
33+
}
34+
return mode
35+
}
36+
37+
func (mode Mode) Valid() bool {
38+
switch mode {
39+
case ModeRelaxed, ModeKnownKeys, ModeStrict:
40+
return true
41+
default:
42+
return false
43+
}
44+
}
45+
46+
func (mode Mode) String() string {
47+
return string(mode)
48+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
)
7+
8+
const DefaultAccountID = "123456789012"
9+
10+
type Status string
11+
12+
const (
13+
StatusMissing Status = "missing"
14+
StatusRelaxed Status = "relaxed"
15+
StatusKnown Status = "known"
16+
StatusUnknownKey Status = "unknown_key"
17+
StatusInvalid Status = "invalid"
18+
)
19+
20+
type Error struct {
21+
Code string
22+
Message string
23+
StatusCode int
24+
}
25+
26+
type Context struct {
27+
Mode Mode
28+
Status Status
29+
Signature Signature
30+
Credential *Credential
31+
AccountID string
32+
PrincipalARN string
33+
Error *Error
34+
StrictSignatureValidation bool
35+
}
36+
37+
type Options struct {
38+
Mode Mode
39+
Store *Store
40+
DefaultAccountID string
41+
DefaultPrincipalARN string
42+
}
43+
44+
func Resolve(req *http.Request, options Options) Context {
45+
mode := NormalizeMode(options.Mode)
46+
accountID := firstNonEmpty(options.DefaultAccountID, DefaultAccountID)
47+
ctx := Context{
48+
Mode: mode,
49+
AccountID: accountID,
50+
PrincipalARN: firstNonEmpty(options.DefaultPrincipalARN, defaultPrincipalARN(accountID)),
51+
StrictSignatureValidation: false,
52+
}
53+
if !mode.Valid() {
54+
ctx.Status = StatusInvalid
55+
ctx.Error = &Error{
56+
Code: "InvalidAuthMode",
57+
Message: fmt.Sprintf("Configured AWS auth mode %q is invalid.", options.Mode),
58+
StatusCode: http.StatusInternalServerError,
59+
}
60+
return ctx
61+
}
62+
63+
signature, err := ParseSigV4(req)
64+
if err != nil {
65+
ctx.Status = StatusInvalid
66+
ctx.Error = &Error{
67+
Code: "AuthorizationHeaderMalformed",
68+
Message: err.Error(),
69+
StatusCode: http.StatusBadRequest,
70+
}
71+
return ctx
72+
}
73+
ctx.Signature = signature
74+
if !signature.Present {
75+
ctx.Status = StatusMissing
76+
if mode != ModeRelaxed {
77+
ctx.Error = &Error{
78+
Code: "MissingAuthenticationToken",
79+
Message: "Request is missing AWS authentication credentials.",
80+
StatusCode: http.StatusForbidden,
81+
}
82+
}
83+
return ctx
84+
}
85+
86+
if credential, ok := options.Store.Resolve(signature.AccessKeyID); ok {
87+
if credential.SessionToken != "" && signature.SessionToken != credential.SessionToken {
88+
ctx.Status = StatusInvalid
89+
ctx.Error = &Error{
90+
Code: "InvalidToken",
91+
Message: "The provided token is malformed or otherwise invalid.",
92+
StatusCode: http.StatusForbidden,
93+
}
94+
return ctx
95+
}
96+
ctx.Status = StatusKnown
97+
ctx.Credential = &credential
98+
ctx.AccountID = firstNonEmpty(credential.AccountID, ctx.AccountID)
99+
ctx.PrincipalARN = firstNonEmpty(credential.PrincipalARN, defaultPrincipalARN(ctx.AccountID))
100+
return ctx
101+
}
102+
103+
if mode == ModeRelaxed {
104+
ctx.Status = StatusRelaxed
105+
return ctx
106+
}
107+
108+
ctx.Status = StatusUnknownKey
109+
ctx.Error = &Error{
110+
Code: "InvalidAccessKeyId",
111+
Message: "The AWS access key Id you provided does not exist in our records.",
112+
StatusCode: http.StatusForbidden,
113+
}
114+
return ctx
115+
}
116+
117+
func defaultPrincipalARN(accountID string) string {
118+
return fmt.Sprintf("arn:aws:iam::%s:user/emulate", accountID)
119+
}
120+
121+
func firstNonEmpty(values ...string) string {
122+
for _, value := range values {
123+
if value != "" {
124+
return value
125+
}
126+
}
127+
return ""
128+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
)
9+
10+
const AlgorithmSigV4 = "AWS4-HMAC-SHA256"
11+
12+
type Scope struct {
13+
Date string
14+
Region string
15+
Service string
16+
Terminal string
17+
}
18+
19+
type Signature struct {
20+
Present bool
21+
Algorithm string
22+
AccessKeyID string
23+
Scope Scope
24+
SignedHeaders []string
25+
SignatureValue string
26+
SessionToken string
27+
Presigned bool
28+
RawCredential string
29+
}
30+
31+
func ParseSigV4(req *http.Request) (Signature, error) {
32+
if hasPresignParameters(req) {
33+
return parsePresignedSigV4(req)
34+
}
35+
return parseAuthorizationHeader(req)
36+
}
37+
38+
func ParseCredentialScope(value string) (string, Scope, error) {
39+
value = strings.TrimSpace(value)
40+
if value == "" {
41+
return "", Scope{}, fmt.Errorf("missing SigV4 credential")
42+
}
43+
unescaped, err := url.QueryUnescape(value)
44+
if err == nil {
45+
value = unescaped
46+
}
47+
parts := strings.Split(value, "/")
48+
if len(parts) != 5 {
49+
return "", Scope{}, fmt.Errorf("invalid SigV4 credential scope")
50+
}
51+
if parts[0] == "" {
52+
return "", Scope{}, fmt.Errorf("missing SigV4 access key id")
53+
}
54+
if parts[1] == "" || parts[2] == "" || parts[3] == "" || parts[4] == "" {
55+
return "", Scope{}, fmt.Errorf("invalid SigV4 credential scope")
56+
}
57+
if parts[4] != "aws4_request" {
58+
return "", Scope{}, fmt.Errorf("invalid SigV4 credential scope")
59+
}
60+
return parts[0], Scope{
61+
Date: parts[1],
62+
Region: parts[2],
63+
Service: parts[3],
64+
Terminal: parts[4],
65+
}, nil
66+
}
67+
68+
func hasPresignParameters(req *http.Request) bool {
69+
query := req.URL.Query()
70+
return query.Get("X-Amz-Algorithm") != "" ||
71+
query.Get("X-Amz-Credential") != "" ||
72+
query.Get("X-Amz-Signature") != ""
73+
}
74+
75+
func parsePresignedSigV4(req *http.Request) (Signature, error) {
76+
query := req.URL.Query()
77+
algorithm := strings.TrimSpace(query.Get("X-Amz-Algorithm"))
78+
if algorithm == "" {
79+
return Signature{}, fmt.Errorf("missing SigV4 algorithm")
80+
}
81+
if algorithm != AlgorithmSigV4 {
82+
return Signature{}, fmt.Errorf("unsupported SigV4 algorithm %q", algorithm)
83+
}
84+
rawCredential := query.Get("X-Amz-Credential")
85+
accessKeyID, scope, err := ParseCredentialScope(rawCredential)
86+
if err != nil {
87+
return Signature{}, err
88+
}
89+
signedHeaders := splitSignedHeaders(query.Get("X-Amz-SignedHeaders"))
90+
if len(signedHeaders) == 0 {
91+
return Signature{}, fmt.Errorf("missing SigV4 signed headers")
92+
}
93+
signatureValue := strings.TrimSpace(query.Get("X-Amz-Signature"))
94+
if signatureValue == "" {
95+
return Signature{}, fmt.Errorf("missing SigV4 signature")
96+
}
97+
return Signature{
98+
Present: true,
99+
Algorithm: algorithm,
100+
AccessKeyID: accessKeyID,
101+
Scope: scope,
102+
SignedHeaders: signedHeaders,
103+
SignatureValue: signatureValue,
104+
SessionToken: query.Get("X-Amz-Security-Token"),
105+
Presigned: true,
106+
RawCredential: rawCredential,
107+
}, nil
108+
}
109+
110+
func parseAuthorizationHeader(req *http.Request) (Signature, error) {
111+
header := strings.TrimSpace(req.Header.Get("Authorization"))
112+
if header == "" {
113+
return Signature{}, nil
114+
}
115+
fields := strings.Fields(header)
116+
if len(fields) == 0 {
117+
return Signature{}, nil
118+
}
119+
algorithm := fields[0]
120+
if algorithm != AlgorithmSigV4 {
121+
return Signature{}, fmt.Errorf("unsupported SigV4 algorithm %q", algorithm)
122+
}
123+
params := parseAuthorizationParams(strings.TrimSpace(strings.TrimPrefix(header, algorithm)))
124+
rawCredential := params["Credential"]
125+
accessKeyID, scope, err := ParseCredentialScope(rawCredential)
126+
if err != nil {
127+
return Signature{}, err
128+
}
129+
signedHeaders := splitSignedHeaders(params["SignedHeaders"])
130+
if len(signedHeaders) == 0 {
131+
return Signature{}, fmt.Errorf("missing SigV4 signed headers")
132+
}
133+
signatureValue := strings.TrimSpace(params["Signature"])
134+
if signatureValue == "" {
135+
return Signature{}, fmt.Errorf("missing SigV4 signature")
136+
}
137+
return Signature{
138+
Present: true,
139+
Algorithm: algorithm,
140+
AccessKeyID: accessKeyID,
141+
Scope: scope,
142+
SignedHeaders: signedHeaders,
143+
SignatureValue: signatureValue,
144+
SessionToken: req.Header.Get("X-Amz-Security-Token"),
145+
RawCredential: rawCredential,
146+
}, nil
147+
}
148+
149+
func parseAuthorizationParams(value string) map[string]string {
150+
params := map[string]string{}
151+
for _, part := range strings.Split(value, ",") {
152+
part = strings.TrimSpace(part)
153+
if part == "" {
154+
continue
155+
}
156+
key, value, ok := strings.Cut(part, "=")
157+
if !ok {
158+
continue
159+
}
160+
params[strings.TrimSpace(key)] = strings.TrimSpace(value)
161+
}
162+
return params
163+
}
164+
165+
func splitSignedHeaders(value string) []string {
166+
if strings.TrimSpace(value) == "" {
167+
return nil
168+
}
169+
parts := strings.Split(value, ";")
170+
headers := make([]string, 0, len(parts))
171+
for _, part := range parts {
172+
part = strings.TrimSpace(strings.ToLower(part))
173+
if part != "" {
174+
headers = append(headers, part)
175+
}
176+
}
177+
return headers
178+
}

0 commit comments

Comments
 (0)