Skip to content

Commit b0da9fb

Browse files
authored
feat(auth): implemented SecurityTokenService to handle token exchange (#250)
Signed-off-by: Marc Nuri <[email protected]>
1 parent cfc42b3 commit b0da9fb

File tree

2 files changed

+166
-0
lines changed

2 files changed

+166
-0
lines changed

pkg/http/sts.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package http
2+
3+
import (
4+
"context"
5+
6+
"github.com/coreos/go-oidc/v3/oidc"
7+
"golang.org/x/oauth2"
8+
"golang.org/x/oauth2/google/externalaccount"
9+
)
10+
11+
type staticSubjectTokenSupplier struct {
12+
token string
13+
}
14+
15+
func (s *staticSubjectTokenSupplier) SubjectToken(_ context.Context, _ externalaccount.SupplierOptions) (string, error) {
16+
return s.token, nil
17+
}
18+
19+
var _ externalaccount.SubjectTokenSupplier = &staticSubjectTokenSupplier{}
20+
21+
type SecurityTokenService struct {
22+
*oidc.Provider
23+
ClientId string
24+
ClientSecret string
25+
ExternalAccountAudience string
26+
ExternalAccountScopes []string
27+
}
28+
29+
func (sts *SecurityTokenService) ExternalAccountTokenExchange(ctx context.Context, originalToken *oauth2.Token) (*oauth2.Token, error) {
30+
ts, err := externalaccount.NewTokenSource(ctx, externalaccount.Config{
31+
TokenURL: sts.Endpoint().TokenURL,
32+
ClientID: sts.ClientId,
33+
ClientSecret: sts.ClientSecret,
34+
Audience: sts.ExternalAccountAudience,
35+
SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token",
36+
SubjectTokenSupplier: &staticSubjectTokenSupplier{token: originalToken.AccessToken},
37+
Scopes: sts.ExternalAccountScopes,
38+
})
39+
if err != nil {
40+
return nil, err
41+
}
42+
return ts.Token()
43+
}

pkg/http/sts_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package http
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/containers/kubernetes-mcp-server/internal/test"
11+
"github.com/coreos/go-oidc/v3/oidc"
12+
"golang.org/x/oauth2"
13+
)
14+
15+
func TestExternalAccountTokenExchange(t *testing.T) {
16+
mockServer := test.NewMockServer()
17+
authServer := mockServer.Config().Host
18+
var tokenExchangeRequest *http.Request
19+
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
20+
if req.URL.Path == "/.well-known/openid-configuration" {
21+
w.Header().Set("Content-Type", "application/json")
22+
_, _ = fmt.Fprintf(w, `{
23+
"issuer": "%s",
24+
"authorization_endpoint": "https://mock-oidc-provider/authorize",
25+
"token_endpoint": "%s/token"
26+
}`, authServer, authServer)
27+
return
28+
}
29+
if req.URL.Path == "/token" {
30+
tokenExchangeRequest = req
31+
_ = tokenExchangeRequest.ParseForm()
32+
if tokenExchangeRequest.PostForm.Get("subject_token") != "the-original-access-token" {
33+
http.Error(w, "Invalid subject_token", http.StatusUnauthorized)
34+
return
35+
}
36+
w.Header().Set("Content-Type", "application/json")
37+
_, _ = w.Write([]byte(`{"access_token":"exchanged-access-token","token_type":"Bearer","expires_in":253402297199}`))
38+
return
39+
}
40+
}))
41+
t.Cleanup(mockServer.Close)
42+
provider, err := oidc.NewProvider(t.Context(), authServer)
43+
if err != nil {
44+
t.Fatalf("oidc.NewProvider() error = %v; want nil", err)
45+
}
46+
// With missing Token Source information
47+
_, err = (&SecurityTokenService{Provider: provider}).ExternalAccountTokenExchange(t.Context(), &oauth2.Token{})
48+
t.Run("ExternalAccountTokenExchange with missing token source returns error", func(t *testing.T) {
49+
if err == nil {
50+
t.Fatalf("ExternalAccountTokenExchange() error = nil; want error")
51+
}
52+
if !strings.Contains(err.Error(), "must be set") {
53+
t.Errorf("ExternalAccountTokenExchange() error = %v; want missing required field", err)
54+
}
55+
})
56+
// With valid Token Source information
57+
sts := SecurityTokenService{
58+
Provider: provider,
59+
ClientId: "test-client-id",
60+
ClientSecret: "test-client-secret",
61+
ExternalAccountAudience: "test-audience",
62+
ExternalAccountScopes: []string{"test-scope"},
63+
}
64+
// With Invalid token
65+
_, err = sts.ExternalAccountTokenExchange(t.Context(), &oauth2.Token{
66+
AccessToken: "invalid-access-token",
67+
TokenType: "Bearer",
68+
})
69+
t.Run("ExternalAccountTokenExchange with invalid token returns error", func(t *testing.T) {
70+
if err == nil {
71+
t.Fatalf("ExternalAccountTokenExchange() error = nil; want error")
72+
}
73+
if !strings.Contains(err.Error(), "status code 401: Invalid subject_token") {
74+
t.Errorf("ExternalAccountTokenExchange() error = %v; want invalid_grant: Invalid subject_token", err)
75+
}
76+
})
77+
// With Valid token
78+
exchangeToken, err := sts.ExternalAccountTokenExchange(t.Context(), &oauth2.Token{
79+
AccessToken: "the-original-access-token",
80+
TokenType: "Bearer",
81+
})
82+
t.Run("ExternalAccountTokenExchange with valid token returns new token", func(t *testing.T) {
83+
if err != nil {
84+
t.Errorf("ExternalAccountTokenExchange() error = %v; want nil", err)
85+
}
86+
if exchangeToken == nil {
87+
t.Fatal("ExternalAccountTokenExchange() = nil; want token")
88+
}
89+
if exchangeToken.AccessToken != "exchanged-access-token" {
90+
t.Errorf("exchangeToken.AccessToken = %s; want exchanged-access-token", exchangeToken.AccessToken)
91+
}
92+
})
93+
t.Run("ExternalAccountTokenExchange with valid token sends POST request", func(t *testing.T) {
94+
if tokenExchangeRequest == nil {
95+
t.Fatal("tokenExchangeRequest is nil; want request")
96+
}
97+
if tokenExchangeRequest.Method != "POST" {
98+
t.Errorf("tokenExchangeRequest.Method = %s; want POST", tokenExchangeRequest.Method)
99+
}
100+
})
101+
t.Run("ExternalAccountTokenExchange with valid token has correct form data", func(t *testing.T) {
102+
if tokenExchangeRequest.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
103+
t.Errorf("tokenExchangeRequest.Content-Type = %s; want application/x-www-form-urlencoded", tokenExchangeRequest.Header.Get("Content-Type"))
104+
}
105+
if tokenExchangeRequest.PostForm.Get("audience") != "test-audience" {
106+
t.Errorf("tokenExchangeRequest.PostForm[audience] = %s; want test-audience", tokenExchangeRequest.PostForm.Get("audience"))
107+
}
108+
if tokenExchangeRequest.PostForm.Get("subject_token_type") != "urn:ietf:params:oauth:token-type:access_token" {
109+
t.Errorf("tokenExchangeRequest.PostForm[subject_token_type] = %s; want urn:ietf:params:oauth:token-type:access_token", tokenExchangeRequest.PostForm.Get("subject_token_type"))
110+
}
111+
if tokenExchangeRequest.PostForm.Get("subject_token") != "the-original-access-token" {
112+
t.Errorf("tokenExchangeRequest.PostForm[subject_token] = %s; want the-original-access-token", tokenExchangeRequest.PostForm.Get("subject_token"))
113+
}
114+
if len(tokenExchangeRequest.PostForm["scope"]) == 0 || tokenExchangeRequest.PostForm["scope"][0] != "test-scope" {
115+
t.Errorf("tokenExchangeRequest.PostForm[scope] = %v; want [test-scope]", tokenExchangeRequest.PostForm["scope"])
116+
}
117+
})
118+
t.Run("ExternalAccountTokenExchange with valid token sends correct client credentials header", func(t *testing.T) {
119+
if tokenExchangeRequest.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("test-client-id:test-client-secret")) {
120+
t.Errorf("tokenExchangeRequest.Header[Authorization] = %s; want Basic base64(test-client-id:test-client-secret)", tokenExchangeRequest.Header.Get("Authorization"))
121+
}
122+
})
123+
}

0 commit comments

Comments
 (0)