Skip to content
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
177 changes: 177 additions & 0 deletions app/auth/plugins/aws_oidc/outgoing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package awsoidc

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"

authplugins "github.com/winhowes/AuthTranslator/app/auth"
)

// awsOIDCParams configures the AWS OIDC plugin.
type awsOIDCParams struct {
Audience string `json:"audience"`
Header string `json:"header"`
Prefix string `json:"prefix"`
}

// AWSOIDC fetches an ID token from the AWS Instance Metadata Service (IMDSv2)
// and adds it to outgoing requests.
type AWSOIDC struct{}

// MetadataHost is the base URL for the AWS metadata service. It can be
// overridden in tests.
var MetadataHost = "http://169.254.169.254"

// HTTPClient is used for all metadata requests.
var HTTPClient = &http.Client{Timeout: 5 * time.Second}

var tokenCache = struct {
sync.Mutex
m map[string]cachedToken
}{m: make(map[string]cachedToken)}

type cachedToken struct {
token string
exp time.Time
}

func (a *AWSOIDC) Name() string { return "aws_oidc" }

func (a *AWSOIDC) RequiredParams() []string { return []string{"audience"} }

func (a *AWSOIDC) OptionalParams() []string { return []string{"header", "prefix"} }

func (a *AWSOIDC) ParseParams(m map[string]interface{}) (interface{}, error) {
p, err := authplugins.ParseParams[awsOIDCParams](m)
if err != nil {
return nil, err
}
if p.Audience == "" {
return nil, fmt.Errorf("missing audience")
}
if p.Header == "" {
p.Header = "Authorization"
}
if p.Prefix == "" {
p.Prefix = "Bearer "
}
return p, nil
}

func (a *AWSOIDC) AddAuth(ctx context.Context, r *http.Request, params interface{}) error {
cfg, ok := params.(*awsOIDCParams)
if !ok {
return fmt.Errorf("invalid config")
}
tok, exp := getCachedToken(cfg.Audience)
if tok == "" || time.Now().After(exp.Add(-1*time.Minute)) {
var err error
tok, exp, err = fetchToken(ctx, cfg.Audience)
if err != nil {
return err
}
setCachedToken(cfg.Audience, tok, exp)
}
r.Header.Set(cfg.Header, cfg.Prefix+tok)
return nil
}

func fetchToken(ctx context.Context, aud string) (string, time.Time, error) {
metaToken, err := fetchMetadataToken(ctx)
if err != nil {
return "", time.Time{}, err
}

metaURL := fmt.Sprintf("%s/latest/meta-data/iam/security-credentials/oidc?audience=%s", MetadataHost, url.QueryEscape(aud))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil)
if err != nil {
return "", time.Time{}, err
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use correct IMDS endpoint for AWS OIDC token

The plugin calls http://169.254.169.254/latest/meta-data/iam/security-credentials/oidc?audience=..., but the IMDS iam/security-credentials API expects an attached IAM role name as a path segment and returns STS credential JSON—there is no documented audience query or oidc resource. On real EC2 hosts this GET will return 404 (or an STS credential payload instead of a JWT), so AddAuth will consistently fail to attach any token. Please point this request at a valid AWS OIDC token source or adjust parsing to the actual IMDS response.

Useful? React with 👍 / 👎.

}
req.Header.Set("X-aws-ec2-metadata-token", metaToken)

resp, err := HTTPClient.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", time.Time{}, fmt.Errorf("status %s: %s", resp.Status, body)
}

tokenBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, err
}
tok := string(tokenBytes)
return tok, parseExpiry(tok), nil
}

func fetchMetadataToken(ctx context.Context) (string, error) {
tokenURL := fmt.Sprintf("%s/latest/api/token", MetadataHost)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, tokenURL, nil)
if err != nil {
return "", err
}
req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")

resp, err := HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token fetch status %s: %s", resp.Status, body)
}

tokenBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(tokenBytes), nil
}

func parseExpiry(tok string) time.Time {
parts := strings.Split(tok, ".")
if len(parts) < 2 {
return time.Now().Add(time.Minute)
}
data, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Now().Add(time.Minute)
}
var c struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(data, &c); err != nil || c.Exp == 0 {
return time.Now().Add(time.Minute)
}
return time.Unix(c.Exp, 0)
}

func getCachedToken(aud string) (string, time.Time) {
tokenCache.Lock()
defer tokenCache.Unlock()
ct, ok := tokenCache.m[aud]
if !ok {
return "", time.Time{}
}
return ct.token, ct.exp
}

func setCachedToken(aud, tok string, exp time.Time) {
tokenCache.Lock()
tokenCache.m[aud] = cachedToken{token: tok, exp: exp}
tokenCache.Unlock()
}

func init() { authplugins.RegisterOutgoing(&AWSOIDC{}) }
163 changes: 163 additions & 0 deletions app/auth/plugins/aws_oidc/outgoing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package awsoidc

import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

type testClaims struct {
Exp int64 `json:"exp"`
}

func makeJWT(exp time.Time) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
claims := base64.RawURLEncoding.EncodeToString([]byte(`{"exp":` + fmt.Sprintf("%d", exp.Unix()) + `}`))
return strings.Join([]string{header, claims, "sig"}, ".")
}

func TestAddAuthFetchesAndCachesToken(t *testing.T) {
now := time.Now().Add(2 * time.Minute)
jwt := makeJWT(now)

metaToken := "meta123"
aud := "urn:test"
var requestCount int

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/latest/api/token":
requestCount++
if r.Method != http.MethodPut {
t.Fatalf("expected PUT for token, got %s", r.Method)
}
if ttl := r.Header.Get("X-aws-ec2-metadata-token-ttl-seconds"); ttl == "" {
t.Fatalf("missing TTL header")
}
w.Write([]byte(metaToken))
case "/latest/meta-data/iam/security-credentials/oidc":
requestCount++
if got := r.Header.Get("X-aws-ec2-metadata-token"); got != metaToken {
t.Fatalf("expected metadata token %q, got %q", metaToken, got)
}
if got := r.URL.Query().Get("audience"); got != aud {
t.Fatalf("expected audience %s, got %s", aud, got)
}
w.Write([]byte(jwt))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()

MetadataHost = srv.URL
HTTPClient = srv.Client()
tokenCache.m = map[string]cachedToken{}

plugin := &AWSOIDC{}
paramsRaw, err := plugin.ParseParams(map[string]interface{}{"audience": aud})
if err != nil {
t.Fatalf("parse params: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err := plugin.AddAuth(context.Background(), req, paramsRaw); err != nil {
t.Fatalf("AddAuth: %v", err)
}

if got := req.Header.Get("Authorization"); got != "Bearer "+jwt {
t.Fatalf("unexpected header: %s", got)
}
if requestCount != 2 {
t.Fatalf("expected 2 metadata requests, got %d", requestCount)
}

// Second call should use cache.
req2, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err := plugin.AddAuth(context.Background(), req2, paramsRaw); err != nil {
t.Fatalf("AddAuth second: %v", err)
}
if requestCount != 2 {
t.Fatalf("expected cached token, still %d requests", requestCount)
}
}

func TestExpiresSoonTriggersRefresh(t *testing.T) {
expSoon := time.Now().Add(30 * time.Second)
jwt1 := makeJWT(expSoon)
jwt2 := makeJWT(time.Now().Add(10 * time.Minute))
metaToken := "meta123"
aud := "urn:test"
var stage int

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/latest/api/token":
w.Write([]byte(metaToken))
case "/latest/meta-data/iam/security-credentials/oidc":
if stage == 0 {
w.Write([]byte(jwt1))
} else {
w.Write([]byte(jwt2))
}
stage++
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()

MetadataHost = srv.URL
HTTPClient = srv.Client()
tokenCache.m = map[string]cachedToken{}

plugin := &AWSOIDC{}
paramsRaw, err := plugin.ParseParams(map[string]interface{}{"audience": aud})
if err != nil {
t.Fatalf("parse params: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err := plugin.AddAuth(context.Background(), req, paramsRaw); err != nil {
t.Fatalf("AddAuth: %v", err)
}
req2, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err := plugin.AddAuth(context.Background(), req2, paramsRaw); err != nil {
t.Fatalf("AddAuth second: %v", err)
}
if stage != 2 {
t.Fatalf("expected token refresh, stage %d", stage)
}
}

func TestErrorResponses(t *testing.T) {
aud := "urn:test"

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/latest/api/token":
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("no token"))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()

MetadataHost = srv.URL
HTTPClient = srv.Client()
tokenCache.m = map[string]cachedToken{}

plugin := &AWSOIDC{}
paramsRaw, err := plugin.ParseParams(map[string]interface{}{"audience": aud})
if err != nil {
t.Fatalf("parse params: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err := plugin.AddAuth(context.Background(), req, paramsRaw); err == nil {
t.Fatalf("expected error from metadata token fetch")
}
}
1 change: 1 addition & 0 deletions app/auth/plugins/plugins.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package plugins

import (
_ "github.com/winhowes/AuthTranslator/app/auth/plugins/aws_oidc"
_ "github.com/winhowes/AuthTranslator/app/auth/plugins/azure_oidc"
_ "github.com/winhowes/AuthTranslator/app/auth/plugins/basic"
_ "github.com/winhowes/AuthTranslator/app/auth/plugins/findreplace"
Expand Down
14 changes: 14 additions & 0 deletions docs/auth-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ AuthTranslator’s behaviour is extended by **plugins** – small Go packages th
| Outbound | `google_oidc` | Attaches a Google identity token from the metadata service. |
| Outbound | `gcp_token` | Uses a metadata service access token. |
| Outbound | `azure_oidc` | Retrieves an Azure access token from the Instance Metadata Service. |
| Outbound | `aws_oidc` | Retrieves an AWS OIDC token from the Instance Metadata Service (IMDSv2). |
| Outbound | `hmac_signature` | Computes an HMAC for the request. |
| Outbound | `jwt` | Adds a signed JWT to the request. |
| Outbound | `mtls` | Sends a client certificate and exposes the CN via header. |
Expand Down Expand Up @@ -99,6 +100,19 @@ outgoing_auth:
Obtains an access token from the Azure Instance Metadata Service for the specified `resource`, caches it, and attaches it to the
configured header on each outgoing request.

### Outbound `aws_oidc`

```yaml
outgoing_auth:
- type: aws_oidc
params:
audience: urn:example
header: Authorization # optional (default: Authorization)
prefix: "Bearer " # optional (default: "Bearer ")
```

Retrieves an ID token from the AWS Instance Metadata Service v2 for the provided `audience`, caches it until shortly before expiry, and attaches it to the chosen header on each outgoing request.

---

## Writing your own plugin
Expand Down
Loading