Skip to content

Commit 5087f7b

Browse files
authored
chore: improve fake IDP script (#11602)
* chore: testIDP using static defaults for easier reuse
1 parent f915bdf commit 5087f7b

File tree

5 files changed

+204
-80
lines changed

5 files changed

+204
-80
lines changed

cmd/testidp/main.go

-58
This file was deleted.

coderd/coderdtest/oidctest/idp.go

+92-21
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ import (
3939
"github.com/coder/coder/v2/codersdk"
4040
)
4141

42+
type token struct {
43+
issued time.Time
44+
email string
45+
exp time.Time
46+
}
47+
4248
// FakeIDP is a functional OIDC provider.
4349
// It only supports 1 OIDC client.
4450
type FakeIDP struct {
@@ -65,7 +71,7 @@ type FakeIDP struct {
6571
// That is the various access tokens, refresh tokens, states, etc.
6672
codeToStateMap *syncmap.Map[string, string]
6773
// Token -> Email
68-
accessTokens *syncmap.Map[string, string]
74+
accessTokens *syncmap.Map[string, token]
6975
// Refresh Token -> Email
7076
refreshTokensUsed *syncmap.Map[string, bool]
7177
refreshTokens *syncmap.Map[string, string]
@@ -89,7 +95,8 @@ type FakeIDP struct {
8995
hookAuthenticateClient func(t testing.TB, req *http.Request) (url.Values, error)
9096
serve bool
9197
// optional middlewares
92-
middlewares chi.Middlewares
98+
middlewares chi.Middlewares
99+
defaultExpire time.Duration
93100
}
94101

95102
func StatusError(code int, err error) error {
@@ -134,6 +141,23 @@ func WithRefresh(hook func(email string) error) func(*FakeIDP) {
134141
}
135142
}
136143

144+
func WithDefaultExpire(d time.Duration) func(*FakeIDP) {
145+
return func(f *FakeIDP) {
146+
f.defaultExpire = d
147+
}
148+
}
149+
150+
func WithStaticCredentials(id, secret string) func(*FakeIDP) {
151+
return func(f *FakeIDP) {
152+
if id != "" {
153+
f.clientID = id
154+
}
155+
if secret != "" {
156+
f.clientSecret = secret
157+
}
158+
}
159+
}
160+
137161
// WithExtra returns extra fields that be accessed on the returned Oauth Token.
138162
// These extra fields can override the default fields (id_token, access_token, etc).
139163
func WithMutateToken(mutateToken func(token map[string]interface{})) func(*FakeIDP) {
@@ -155,6 +179,12 @@ func WithLogging(t testing.TB, options *slogtest.Options) func(*FakeIDP) {
155179
}
156180
}
157181

182+
func WithLogger(logger slog.Logger) func(*FakeIDP) {
183+
return func(f *FakeIDP) {
184+
f.logger = logger
185+
}
186+
}
187+
158188
// WithStaticUserInfo is optional, but will return the same user info for
159189
// every user on the /userinfo endpoint.
160190
func WithStaticUserInfo(info jwt.MapClaims) func(*FakeIDP) {
@@ -211,14 +241,15 @@ func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP {
211241
clientSecret: uuid.NewString(),
212242
logger: slog.Make(),
213243
codeToStateMap: syncmap.New[string, string](),
214-
accessTokens: syncmap.New[string, string](),
244+
accessTokens: syncmap.New[string, token](),
215245
refreshTokens: syncmap.New[string, string](),
216246
refreshTokensUsed: syncmap.New[string, bool](),
217247
stateToIDTokenClaims: syncmap.New[string, jwt.MapClaims](),
218248
refreshIDTokenClaims: syncmap.New[string, jwt.MapClaims](),
219249
hookOnRefresh: func(_ string) error { return nil },
220250
hookUserInfo: func(email string) (jwt.MapClaims, error) { return jwt.MapClaims{}, nil },
221251
hookValidRedirectURL: func(redirectURL string) error { return nil },
252+
defaultExpire: time.Minute * 5,
222253
}
223254

224255
for _, opt := range opts {
@@ -265,15 +296,31 @@ func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) {
265296
Algorithms: []string{
266297
"RS256",
267298
},
299+
ExternalAuthURL: u.ResolveReference(&url.URL{Path: "/external-auth-validate/user"}).String(),
268300
}
269301
}
270302

271303
// realServer turns the FakeIDP into a real http server.
272304
func (f *FakeIDP) realServer(t testing.TB) *httptest.Server {
273305
t.Helper()
274306

307+
srvURL := "localhost:0"
308+
issURL, err := url.Parse(f.issuer)
309+
if err == nil {
310+
if issURL.Hostname() == "localhost" || issURL.Hostname() == "127.0.0.1" {
311+
srvURL = issURL.Host
312+
}
313+
}
314+
315+
l, err := net.Listen("tcp", srvURL)
316+
require.NoError(t, err, "failed to create listener")
317+
275318
ctx, cancel := context.WithCancel(context.Background())
276-
srv := httptest.NewUnstartedServer(f.handler)
319+
srv := &httptest.Server{
320+
Listener: l,
321+
Config: &http.Server{Handler: f.handler, ReadHeaderTimeout: time.Second * 5},
322+
}
323+
277324
srv.Config.BaseContext = func(_ net.Listener) context.Context {
278325
return ctx
279326
}
@@ -495,6 +542,8 @@ type ProviderJSON struct {
495542
JWKSURL string `json:"jwks_uri"`
496543
UserInfoURL string `json:"userinfo_endpoint"`
497544
Algorithms []string `json:"id_token_signing_alg_values_supported"`
545+
// This is custom
546+
ExternalAuthURL string `json:"external_auth_url"`
498547
}
499548

500549
// newCode enforces the code exchanged is actually a valid code
@@ -507,9 +556,13 @@ func (f *FakeIDP) newCode(state string) string {
507556

508557
// newToken enforces the access token exchanged is actually a valid access token
509558
// created by the IDP.
510-
func (f *FakeIDP) newToken(email string) string {
559+
func (f *FakeIDP) newToken(email string, expires time.Time) string {
511560
accessToken := uuid.NewString()
512-
f.accessTokens.Store(accessToken, email)
561+
f.accessTokens.Store(accessToken, token{
562+
issued: time.Now(),
563+
email: email,
564+
exp: expires,
565+
})
513566
return accessToken
514567
}
515568

@@ -525,10 +578,15 @@ func (f *FakeIDP) authenticateBearerTokenRequest(t testing.TB, req *http.Request
525578

526579
auth := req.Header.Get("Authorization")
527580
token := strings.TrimPrefix(auth, "Bearer ")
528-
_, ok := f.accessTokens.Load(token)
581+
authToken, ok := f.accessTokens.Load(token)
529582
if !ok {
530583
return "", xerrors.New("invalid access token")
531584
}
585+
586+
if !authToken.exp.IsZero() && authToken.exp.Before(time.Now()) {
587+
return "", xerrors.New("access token expired")
588+
}
589+
532590
return token, nil
533591
}
534592

@@ -653,7 +711,8 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
653711
mux.Handle(tokenPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
654712
values, err := f.authenticateOIDCClientRequest(t, r)
655713
f.logger.Info(r.Context(), "http idp call token",
656-
slog.Error(err),
714+
slog.F("valid", err == nil),
715+
slog.F("grant_type", values.Get("grant_type")),
657716
slog.F("values", values.Encode()),
658717
)
659718
if err != nil {
@@ -731,15 +790,15 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
731790
return
732791
}
733792

734-
exp := time.Now().Add(time.Minute * 5)
793+
exp := time.Now().Add(f.defaultExpire)
735794
claims["exp"] = exp.UnixMilli()
736795
email := getEmail(claims)
737796
refreshToken := f.newRefreshTokens(email)
738797
token := map[string]interface{}{
739-
"access_token": f.newToken(email),
798+
"access_token": f.newToken(email, exp),
740799
"refresh_token": refreshToken,
741800
"token_type": "Bearer",
742-
"expires_in": int64((time.Minute * 5).Seconds()),
801+
"expires_in": int64((f.defaultExpire).Seconds()),
743802
"id_token": f.encodeClaims(t, claims),
744803
}
745804
if f.hookMutateToken != nil {
@@ -754,25 +813,31 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
754813

755814
validateMW := func(rw http.ResponseWriter, r *http.Request) (email string, ok bool) {
756815
token, err := f.authenticateBearerTokenRequest(t, r)
757-
f.logger.Info(r.Context(), "http call idp user info",
758-
slog.Error(err),
759-
slog.F("url", r.URL.String()),
760-
)
761816
if err != nil {
762-
http.Error(rw, fmt.Sprintf("invalid user info request: %s", err.Error()), http.StatusBadRequest)
817+
http.Error(rw, fmt.Sprintf("invalid user info request: %s", err.Error()), http.StatusUnauthorized)
763818
return "", false
764819
}
765820

766-
email, ok = f.accessTokens.Load(token)
821+
authToken, ok := f.accessTokens.Load(token)
767822
if !ok {
768823
t.Errorf("access token user for user_info has no email to indicate which user")
769-
http.Error(rw, "invalid access token, missing user info", http.StatusBadRequest)
824+
http.Error(rw, "invalid access token, missing user info", http.StatusUnauthorized)
825+
return "", false
826+
}
827+
828+
if !authToken.exp.IsZero() && authToken.exp.Before(time.Now()) {
829+
http.Error(rw, "auth token expired", http.StatusUnauthorized)
770830
return "", false
771831
}
772-
return email, true
832+
833+
return authToken.email, true
773834
}
774835
mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
775836
email, ok := validateMW(rw, r)
837+
f.logger.Info(r.Context(), "http userinfo endpoint",
838+
slog.F("valid", ok),
839+
slog.F("email", email),
840+
)
776841
if !ok {
777842
return
778843
}
@@ -790,6 +855,10 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
790855
// should be strict, and this one needs to handle sub routes.
791856
mux.Mount("/external-auth-validate/", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
792857
email, ok := validateMW(rw, r)
858+
f.logger.Info(r.Context(), "http external auth validate",
859+
slog.F("valid", ok),
860+
slog.F("email", email),
861+
)
793862
if !ok {
794863
return
795864
}
@@ -941,7 +1010,7 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
9411010
}
9421011
f.externalProviderID = id
9431012
f.externalAuthValidate = func(email string, rw http.ResponseWriter, r *http.Request) {
944-
newPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/external-auth-validate/%s", id))
1013+
newPath := strings.TrimPrefix(r.URL.Path, "/external-auth-validate")
9451014
switch newPath {
9461015
// /user is ALWAYS supported under the `/` path too.
9471016
case "/user", "/", "":
@@ -965,18 +1034,20 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
9651034
}
9661035
instrumentF := promoauth.NewFactory(prometheus.NewRegistry())
9671036
cfg := &externalauth.Config{
1037+
DisplayName: id,
9681038
InstrumentedOAuth2Config: instrumentF.New(f.clientID, f.OIDCConfig(t, nil)),
9691039
ID: id,
9701040
// No defaults for these fields by omitting the type
9711041
Type: "",
9721042
DisplayIcon: f.WellknownConfig().UserInfoURL,
9731043
// Omit the /user for the validate so we can easily append to it when modifying
9741044
// the cfg for advanced tests.
975-
ValidateURL: f.issuerURL.ResolveReference(&url.URL{Path: fmt.Sprintf("/external-auth-validate/%s", id)}).String(),
1045+
ValidateURL: f.issuerURL.ResolveReference(&url.URL{Path: "/external-auth-validate/"}).String(),
9761046
}
9771047
for _, opt := range opts {
9781048
opt(cfg)
9791049
}
1050+
f.updateIssuerURL(t, f.issuer)
9801051
return cfg
9811052
}
9821053

coderd/externalauth_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func TestExternalAuthByID(t *testing.T) {
126126
client := coderdtest.New(t, &coderdtest.Options{
127127
ExternalAuthConfigs: []*externalauth.Config{
128128
fake.ExternalAuthConfig(t, providerID, routes, func(cfg *externalauth.Config) {
129-
cfg.AppInstallationsURL = cfg.ValidateURL + "/installs"
129+
cfg.AppInstallationsURL = strings.TrimSuffix(cfg.ValidateURL, "/") + "/installs"
130130
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
131131
}),
132132
},
File renamed without changes.

0 commit comments

Comments
 (0)