@@ -19,8 +19,6 @@ import (
1919"testing"
2020"time"
2121
22- "github.com/coder/coder/v2/coderd/util/syncmap"
23-
2422"github.com/coreos/go-oidc/v3/oidc"
2523"github.com/go-chi/chi/v5"
2624"github.com/go-jose/go-jose/v3"
@@ -34,22 +32,32 @@ import (
3432"cdr.dev/slog"
3533"cdr.dev/slog/sloggers/slogtest"
3634"github.com/coder/coder/v2/coderd"
35+ "github.com/coder/coder/v2/coderd/externalauth"
36+ "github.com/coder/coder/v2/coderd/util/syncmap"
3737"github.com/coder/coder/v2/codersdk"
3838)
3939
4040// FakeIDP is a functional OIDC provider.
4141// It only supports 1 OIDC client.
4242type FakeIDP struct {
43- issuer string
44- key * rsa.PrivateKey
45- provider ProviderJSON
46- handler http.Handler
47- cfg * oauth2.Config
43+ issuer string
44+ issuerURL * url.URL
45+ key * rsa.PrivateKey
46+ provider ProviderJSON
47+ handler http.Handler
48+ cfg * oauth2.Config
4849
4950// clientID to be used by coderd
5051clientID string
5152clientSecret string
52- logger slog.Logger
53+ // externalProviderID is optional to match the provider in coderd for
54+ // redirectURLs.
55+ externalProviderID string
56+ logger slog.Logger
57+ // externalAuthValidate will be called when the user tries to validate their
58+ // external auth. The fake IDP will reject any invalid tokens, so this just
59+ // controls the response payload after a successfully authed token.
60+ externalAuthValidate func (email string ,rw http.ResponseWriter ,r * http.Request )
5361
5462// These maps are used to control the state of the IDP.
5563// That is the various access tokens, refresh tokens, states, etc.
@@ -222,6 +230,7 @@ func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) {
222230require .NoError (t ,err ,"invalid issuer URL" )
223231
224232f .issuer = issuer
233+ f .issuerURL = u
225234// ProviderJSON is the JSON representation of the OpenID Connect provider
226235// These are all the urls that the IDP will respond to.
227236f .provider = ProviderJSON {
@@ -347,6 +356,47 @@ func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idToken
347356return user ,res
348357}
349358
359+ // ExternalLogin does the oauth2 flow for external auth providers. This requires
360+ // an authenticated coder client.
361+ func (f * FakeIDP )ExternalLogin (t testing.TB ,client * codersdk.Client ,opts ... func (r * http.Request )) {
362+ coderOauthURL ,err := client .URL .Parse (fmt .Sprintf ("/external-auth/%s/callback" ,f .externalProviderID ))
363+ require .NoError (t ,err )
364+ f .SetRedirect (t ,coderOauthURL .String ())
365+
366+ cli := f .HTTPClient (client .HTTPClient )
367+ cli .CheckRedirect = func (req * http.Request ,via []* http.Request )error {
368+ // Store the idTokenClaims to the specific state request. This ties
369+ // the claims 1:1 with a given authentication flow.
370+ state := req .URL .Query ().Get ("state" )
371+ f .stateToIDTokenClaims .Store (state , jwt.MapClaims {})
372+ return nil
373+ }
374+
375+ ctx ,cancel := context .WithCancel (context .Background ())
376+ t .Cleanup (cancel )
377+ req ,err := http .NewRequestWithContext (ctx ,"GET" ,coderOauthURL .String (),nil )
378+ require .NoError (t ,err )
379+ // External auth flow requires the user be authenticated.
380+ headerName := client .SessionTokenHeader
381+ if headerName == "" {
382+ headerName = codersdk .SessionTokenHeader
383+ }
384+ req .Header .Set (headerName ,client .SessionToken ())
385+ if cli .Jar == nil {
386+ cli .Jar ,err = cookiejar .New (nil )
387+ require .NoError (t ,err ,"failed to create cookie jar" )
388+ }
389+
390+ for _ ,opt := range opts {
391+ opt (req )
392+ }
393+
394+ res ,err := cli .Do (req )
395+ require .NoError (t ,err )
396+ require .Equal (t ,http .StatusOK ,res .StatusCode ,"client failed to login" )
397+ _ = res .Body .Close ()
398+ }
399+
350400// OIDCCallback will emulate the IDP redirecting back to the Coder callback.
351401// This is helpful if no Coderd exists because the IDP needs to redirect to
352402// something.
@@ -640,23 +690,31 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
640690_ = json .NewEncoder (rw ).Encode (token )
641691}))
642692
643- mux . Handle ( userInfoPath , http . HandlerFunc ( func (rw http.ResponseWriter ,r * http.Request ) {
693+ validateMW := func (rw http.ResponseWriter ,r * http.Request ) ( email string , ok bool ) {
644694token ,err := f .authenticateBearerTokenRequest (t ,r )
645695f .logger .Info (r .Context (),"http call idp user info" ,
646696slog .Error (err ),
647697slog .F ("url" ,r .URL .String ()),
648698)
649699if err != nil {
650700http .Error (rw ,fmt .Sprintf ("invalid user info request: %s" ,err .Error ()),http .StatusBadRequest )
651- return
701+ return "" , false
652702}
653703
654- email ,ok : =f .accessTokens .Load (token )
704+ email ,ok = f .accessTokens .Load (token )
655705if ! ok {
656706t .Errorf ("access token user for user_info has no email to indicate which user" )
657707http .Error (rw ,"invalid access token, missing user info" ,http .StatusBadRequest )
708+ return "" ,false
709+ }
710+ return email ,true
711+ }
712+ mux .Handle (userInfoPath ,http .HandlerFunc (func (rw http.ResponseWriter ,r * http.Request ) {
713+ email ,ok := validateMW (rw ,r )
714+ if ! ok {
658715return
659716}
717+
660718claims ,err := f .hookUserInfo (email )
661719if err != nil {
662720http .Error (rw ,fmt .Sprintf ("user info hook returned error: %s" ,err .Error ()),httpErrorCode (http .StatusBadRequest ,err ))
@@ -665,6 +723,24 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
665723_ = json .NewEncoder (rw ).Encode (claims )
666724}))
667725
726+ // There is almost no difference between this and /userinfo.
727+ // The main tweak is that this route is "mounted" vs "handle" because "/userinfo"
728+ // should be strict, and this one needs to handle sub routes.
729+ mux .Mount ("/external-auth-validate/" ,http .HandlerFunc (func (rw http.ResponseWriter ,r * http.Request ) {
730+ email ,ok := validateMW (rw ,r )
731+ if ! ok {
732+ return
733+ }
734+
735+ if f .externalAuthValidate == nil {
736+ t .Errorf ("missing external auth validate handler" )
737+ http .Error (rw ,"missing external auth validate handler" ,http .StatusBadRequest )
738+ return
739+ }
740+
741+ f .externalAuthValidate (email ,rw ,r )
742+ }))
743+
668744mux .Handle (keysPath ,http .HandlerFunc (func (rw http.ResponseWriter ,r * http.Request ) {
669745f .logger .Info (r .Context (),"http call idp /keys" )
670746set := jose.JSONWebKeySet {
@@ -767,6 +843,80 @@ func (f *FakeIDP) SetCoderdCallbackHandler(handler http.HandlerFunc) {
767843})
768844}
769845
846+ // ExternalAuthConfigOptions exists to provide additional functionality ontop
847+ // of the standard "validate" url. Some providers like github we actually parse
848+ // the response from the validate URL to gain additional information.
849+ type ExternalAuthConfigOptions struct {
850+ // ValidatePayload is the payload that is used when the user calls the
851+ // equivalent of "userinfo" for oauth2. This is not standardized, so is
852+ // different for each provider type.
853+ ValidatePayload func (email string )interface {}
854+
855+ // routes is more advanced usage. This allows the caller to
856+ // completely customize the response. It captures all routes under the /external-auth-validate/*
857+ // so the caller can do whatever they want and even add routes.
858+ routes map [string ]func (email string ,rw http.ResponseWriter ,r * http.Request )
859+ }
860+
861+ func (o * ExternalAuthConfigOptions )AddRoute (route string ,handle func (email string ,rw http.ResponseWriter ,r * http.Request ))* ExternalAuthConfigOptions {
862+ if route == "/" || route == "" || route == "/user" {
863+ panic ("cannot override the /user route. Use ValidatePayload instead" )
864+ }
865+ if ! strings .HasPrefix (route ,"/" ) {
866+ route = "/" + route
867+ }
868+ if o .routes == nil {
869+ o .routes = make (map [string ]func (email string ,rw http.ResponseWriter ,r * http.Request ))
870+ }
871+ o .routes [route ]= handle
872+ return o
873+ }
874+
875+ // ExternalAuthConfig is the config for external auth providers.
876+ func (f * FakeIDP )ExternalAuthConfig (t testing.TB ,id string ,custom * ExternalAuthConfigOptions ,opts ... func (cfg * externalauth.Config ))* externalauth.Config {
877+ if custom == nil {
878+ custom = & ExternalAuthConfigOptions {}
879+ }
880+ f .externalProviderID = id
881+ f .externalAuthValidate = func (email string ,rw http.ResponseWriter ,r * http.Request ) {
882+ newPath := strings .TrimPrefix (r .URL .Path ,fmt .Sprintf ("/external-auth-validate/%s" ,id ))
883+ switch newPath {
884+ // /user is ALWAYS supported under the `/` path too.
885+ case "/user" ,"/" ,"" :
886+ var payload interface {}= "OK"
887+ if custom .ValidatePayload != nil {
888+ payload = custom .ValidatePayload (email )
889+ }
890+ _ = json .NewEncoder (rw ).Encode (payload )
891+ default :
892+ if custom .routes == nil {
893+ custom .routes = make (map [string ]func (email string ,rw http.ResponseWriter ,r * http.Request ))
894+ }
895+ handle ,ok := custom .routes [newPath ]
896+ if ! ok {
897+ t .Errorf ("missing route handler for %s" ,newPath )
898+ http .Error (rw ,fmt .Sprintf ("missing route handler for %s" ,newPath ),http .StatusBadRequest )
899+ return
900+ }
901+ handle (email ,rw ,r )
902+ }
903+ }
904+ cfg := & externalauth.Config {
905+ OAuth2Config :f .OIDCConfig (t ,nil ),
906+ ID :id ,
907+ // No defaults for these fields by omitting the type
908+ Type :"" ,
909+ DisplayIcon :f .WellknownConfig ().UserInfoURL ,
910+ // Omit the /user for the validate so we can easily append to it when modifying
911+ // the cfg for advanced tests.
912+ ValidateURL :f .issuerURL .ResolveReference (& url.URL {Path :fmt .Sprintf ("/external-auth-validate/%s" ,id )}).String (),
913+ }
914+ for _ ,opt := range opts {
915+ opt (cfg )
916+ }
917+ return cfg
918+ }
919+
770920// OIDCConfig returns the OIDC config to use for Coderd.
771921func (f * FakeIDP )OIDCConfig (t testing.TB ,scopes []string ,opts ... func (cfg * coderd.OIDCConfig ))* coderd.OIDCConfig {
772922t .Helper ()