diff --git a/README.md b/README.md index cbd5146c..8ccab4a0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This library provides "social login" with Github, Google, Facebook, Microsoft, Twitter, Yandex, Battle.net, Apple, Patreon and Telegram as well as custom auth providers and email verification. - Multiple oauth2 providers can be used at the same time +- Support of ID Tokens (OpenID) for loading user details - Special `dev` provider allows local testing and development - JWT stored in a secure cookie with XSRF protection. Cookies can be session-only - Minimal scopes with user name, id and picture (avatar) only @@ -320,6 +321,36 @@ In order to add a new oauth2 provider following input is required: service.AddCustomProvider("custom123", auth.Client{Cid: "cid", Csecret: "csecret"}, prov.HandlerOpt) ``` +### Using ID Tokens (OpenID Connect) + +Example of configuring OAuth2 with OpenID Connect: + +```go +c := auth.Client{ + Cid: os.Getenv("AEXMPL_CIDE"), + Csecret: os.Getenv("AEXMPL_CSED"), +} + +service.AddOpenIDProvider("my-openid", c, provider.CustomHandlerOpt{ + Endpoint: oauth2.Endpoint{ + AuthURL: "https://my-open-id-provider.com/oauth2/authorize", + TokenURL: "https://my-open-id-provider.com/oauth2/token", + }, + JwksURL: "https://my-open-id-provider.com/.well-known/jwks", + InfoURL: "https://my-open-id-provider.com/user/", + MapUserFn: func (data provider.UserData, _ []byte) token.User { + userInfo := token.User{ + ID: data.Value("sub"), // standard OpenID Connect claims are available + Name: data.Value("given_name"), + } + return userInfo + }, + Scopes: []string{"openid", "email", "profile"}, // defaulted to "openid" if not specified +}) +``` + +JWKS are loaded on application start, and then cached. There is no background refresh, but requesting unknown key (kid) will trigger keys reload. + ### Self-implemented auth handler Additionally it is possible to implement own auth handler. It may be useful if auth provider does not conform to oauth standard. Self-implemented handler has to implement `provider.Provider` interface. ```go diff --git a/auth.go b/auth.go index d41c6eeb..149bd4bb 100644 --- a/auth.go +++ b/auth.go @@ -220,7 +220,6 @@ func (s *Service) Middleware() middleware.Authenticator { // AddProvider adds provider for given name func (s *Service) AddProvider(name, cid, csecret string) { - p := provider.Params{ URL: s.opts.URL, JwtService: s.jwtService, @@ -270,6 +269,20 @@ func (s *Service) AddDevProvider(port int) { s.providers = append(s.providers, provider.NewService(provider.NewDev(p))) } +// AddDevOpenIDProvider with a custom port that is using OpenID tokens +func (s *Service) AddDevOpenIDProvider(port int) { + p := provider.Params{ + URL: s.opts.URL, + JwtService: s.jwtService, + Issuer: s.issuer, + AvatarSaver: s.avatarProxy, + L: s.logger, + Port: port, + UseOpenID: true, + } + s.providers = append(s.providers, provider.NewService(provider.NewDev(p))) +} + // AddAppleProvider allow SignIn with Apple ID func (s *Service) AddAppleProvider(appleConfig provider.AppleConfig, privKeyLoader provider.PrivateKeyLoaderInterface) error { p := provider.Params{ @@ -306,6 +319,27 @@ func (s *Service) AddCustomProvider(name string, client Client, copts provider.C s.authMiddleware.Providers = s.providers } +// AddOpenIDProvider adds custom provider (e.g. https://gopkg.in/oauth2.v3) that uses OpenID instead of pure OAuth2 +func (s *Service) AddOpenIDProvider(name string, client Client, copts provider.CustomHandlerOpt) { + p := provider.Params{ + URL: s.opts.URL, + JwtService: s.jwtService, + Issuer: s.issuer, + AvatarSaver: s.avatarProxy, + Cid: client.Cid, + Csecret: client.Csecret, + L: s.logger, + UseOpenID: true, + } + + if copts.Scopes == nil { + copts.Scopes = []string{"openid"} + } + + s.providers = append(s.providers, provider.NewService(provider.NewCustom(name, p, copts))) + s.authMiddleware.Providers = s.providers +} + // AddDirectProvider adds provider with direct check against data store // it doesn't do any handshake and uses provided credChecker to verify user and password from the request func (s *Service) AddDirectProvider(name string, credChecker provider.CredChecker) { diff --git a/auth_test.go b/auth_test.go index 601cd92d..747513cb 100644 --- a/auth_test.go +++ b/auth_test.go @@ -3,6 +3,7 @@ package auth import ( "context" "encoding/json" + "golang.org/x/oauth2" "io" "io/ioutil" "net" @@ -374,6 +375,53 @@ func TestDirectProvider(t *testing.T) { assert.NoError(t, resp.Body.Close()) } +func TestDevOpenIDProvider(t *testing.T) { + service := NewService(Opts{Logger: logger.Std, SecretReader: token.SecretFunc(func(aud string) (string, error) { + return "secret", nil + })}) + service.AddDevOpenIDProvider(18089) + + devAuth, err := service.DevAuth() + require.NoError(t, err) + + go devAuth.Run(context.Background()) + defer devAuth.Shutdown() + + for i := 1; i < 20; i++ { + time.Sleep(time.Duration(i*10) * time.Millisecond) + + dial, e := net.Dial("tcp", "localhost:18089") + if e == nil { + e = dial.Close() + require.NoError(t, e) + + break + } + } + + jwksResp, err := http.Get("http://localhost:18089/jwks") + require.NoError(t, err) + assert.Equal(t, 200, jwksResp.StatusCode) + + service.AddOpenIDProvider("openid", Client{Cid: "cid", Csecret: "csecret"}, provider.CustomHandlerOpt{ + Endpoint: oauth2.Endpoint{ + AuthURL: "http://localhost:18089/login/oauth/authorize", + TokenURL: "http://localhost:18089/login/oauth/access_token", + AuthStyle: oauth2.AuthStyleAutoDetect, + }, + InfoURL: "http://localhost:18089/user", + JwksURL: "http://localhost:18089/jwks", + MapUserFn: func(data provider.UserData, bytes []byte) token.User { + return token.User{ + Name: data.Value("sub"), + } + }, + }) + + assert.Len(t, service.Providers(), 2) + // OpenID flow is tested in the openid_test.go, but coverage tool isn't picking it up +} + func TestDirectProvider_WithCustomUserIDFunc(t *testing.T) { _, teardown := prepService(t) defer teardown() diff --git a/go.mod b/go.mod index 869e836d..d9998f2d 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,11 @@ require ( require ( cloud.google.com/go/compute v1.6.1 // indirect + github.com/MicahParks/keyfunc v1.1.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-stack/stack v1.8.1 // indirect + github.com/golang-jwt/jwt/v4 v4.4.1 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.1.2 // indirect diff --git a/go.sum b/go.sum index 8c21340d..04074ce2 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/MicahParks/keyfunc v1.1.0 h1:9NcnRwS0ciuVeVNi+vTdYVMTmk62OID7VlG6y9BgLK0= +github.com/MicahParks/keyfunc v1.1.0/go.mod h1:a4yfunv77gZ0RgTNw7tOYS+bjtHk5565e+1dPz+YJI8= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= @@ -115,6 +117,8 @@ github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/provider/custom_server.go b/provider/custom_server.go index f5bde31c..9a07f2bd 100644 --- a/provider/custom_server.go +++ b/provider/custom_server.go @@ -24,6 +24,7 @@ import ( type CustomHandlerOpt struct { Endpoint oauth2.Endpoint InfoURL string + JwksURL string MapUserFn func(UserData, []byte) token.User Scopes []string } @@ -208,6 +209,7 @@ func NewCustom(name string, p Params, copts CustomHandlerOpt) Oauth2Handler { endpoint: copts.Endpoint, scopes: copts.Scopes, infoURL: copts.InfoURL, + jwksURL: copts.JwksURL, mapUser: copts.MapUserFn, }) } diff --git a/provider/dev_provider.go b/provider/dev_provider.go index b3d2b769..2249d32b 100644 --- a/provider/dev_provider.go +++ b/provider/dev_provider.go @@ -2,8 +2,14 @@ package provider import ( "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" "fmt" + "github.com/golang-jwt/jwt" "html/template" + "math/big" "net/http" "strings" "sync" @@ -25,9 +31,10 @@ const defDevAuthPort = 8084 // desired user name, this is the mode used for development. Non-interactive mode for tests only. type DevAuthServer struct { logger.L - Provider Oauth2Handler - Automatic bool - GetEmailFn func(string) string + Provider Oauth2Handler + Automatic bool + GetEmailFn func(string) string + CustomizeIDTokenFn func(map[string]interface{}) map[string]interface{} username string // unsafe, but fine for dev httpServer *http.Server @@ -50,6 +57,15 @@ func (d *DevAuthServer) Run(ctx context.Context) { //nolint (gocyclo) return } + var privateKey *rsa.PrivateKey + if d.Provider.UseOpenID { + privateKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + d.Logf("[ERROR] failed to generate keys") + return + } + } + d.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", d.Provider.Port), Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -74,7 +90,8 @@ func (d *DevAuthServer) Run(ctx context.Context) { //nolint (gocyclo) } state := r.URL.Query().Get("state") - callbackURL := fmt.Sprintf("%s?code=g0ZGZmNjVmOWI&state=%s", d.Provider.conf.RedirectURL, state) + redirectURI := r.URL.Query().Get("redirect_uri") + callbackURL := fmt.Sprintf("%s?code=g0ZGZmNjVmOWI&state=%s", redirectURI, state) d.Logf("[DEBUG] callback url=%s", callbackURL) w.Header().Add("Location", callbackURL) w.WriteHeader(http.StatusFound) @@ -87,13 +104,95 @@ func (d *DevAuthServer) Run(ctx context.Context) { //nolint (gocyclo) "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", "scope":"create", "state":"12345678" - }` + }` + + if d.Provider.UseOpenID { + email := d.username + if d.GetEmailFn != nil { + email = d.GetEmailFn(d.username) + } + + idClaims := map[string]interface{}{ + // required OpenID claims + "iss": "dev-auth", + "sub": "%s", + "aud": "client-id", + "iat": time.Now().Unix(), + "exp": time.Now().Add(1 * time.Hour).Unix(), + + // optional OpenID claims + "picture": fmt.Sprintf("http://127.0.0.1:%d/avatar?user=%s", d.Provider.Port, d.username), + "given_name": d.username, + "email": email, + } + + if d.CustomizeIDTokenFn != nil { + idClaims = d.CustomizeIDTokenFn(idClaims) + } + + tk := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(idClaims)) + tk.Header["kid"] = "dev-auth-key-1" + + signedTk, e := tk.SignedString(privateKey) + if e != nil { + d.Logf("[ERROR] failed to sign ID token") + w.WriteHeader(http.StatusInternalServerError) + return + } + + res = fmt.Sprintf(`{ + "access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + "id_token": "%s", + "token_type":"bearer", + "expires_in":3600, + "refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", + "scope":"create", + "state":"12345678" + }`, signedTk) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") if _, err = w.Write([]byte(res)); err != nil { w.WriteHeader(http.StatusInternalServerError) return } + case strings.HasPrefix(r.URL.Path, "/jwks") && d.Provider.UseOpenID: + type jwkKey struct { + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + Alg string `json:"alg"` + Kid string `json:"kid"` + } + + e := big.NewInt(int64(privateKey.E)) + key := jwkKey{ + Kty: "RSA", + Alg: "RS256", + Kid: "dev-auth-key-1", + N: base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(e.Bytes()), + } + + jwks, er := json.Marshal(struct { + Keys []jwkKey `json:"keys"` + }{ + Keys: []jwkKey{key}, + }) + if er != nil { + d.Logf("[ERROR] failed to marshal jwks") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + wr, er := w.Write(jwks) + if er != nil || wr == 0 { + d.Logf("[ERROR] failed to write jwks") + w.WriteHeader(http.StatusInternalServerError) + } + case strings.HasPrefix(r.URL.Path, "/user"): ava := fmt.Sprintf("http://127.0.0.1:%d/avatar?user=%s", d.Provider.Port, d.username) res := fmt.Sprintf(`{ @@ -175,13 +274,24 @@ func NewDev(p Params) Oauth2Handler { }, scopes: []string{"user:email"}, infoURL: fmt.Sprintf("http://127.0.0.1:%d/user", p.Port), + jwksURL: fmt.Sprintf("http://127.0.0.1:%d/jwks", p.Port), mapUser: func(data UserData, _ []byte) token.User { + if p.UseOpenID { + return token.User{ + ID: data.Value("sub"), + Name: data.Value("given_name"), + Picture: data.Value("picture"), + Email: data.Value("email"), + } + } + userInfo := token.User{ ID: data.Value("id"), Name: data.Value("name"), Picture: data.Value("picture"), Email: data.Value("email"), } + return userInfo }, }) diff --git a/provider/oauth2.go b/provider/oauth2.go index d2c8dd7c..56f77996 100644 --- a/provider/oauth2.go +++ b/provider/oauth2.go @@ -4,19 +4,25 @@ import ( "context" "encoding/json" "fmt" + "github.com/MicahParks/keyfunc" + "github.com/pkg/errors" "io" "net/http" "strings" + "sync" "time" "github.com/go-pkgz/rest" "github.com/golang-jwt/jwt" + jwtv4 "github.com/golang-jwt/jwt/v4" "golang.org/x/oauth2" "github.com/go-pkgz/auth/logger" "github.com/go-pkgz/auth/token" ) +const clockSkew = 10 * time.Second + // Oauth2Handler implements /login, /callback and /logout handlers from aouth2 flow type Oauth2Handler struct { Params @@ -24,10 +30,13 @@ type Oauth2Handler struct { // all of these fields specific to particular oauth2 provider name string infoURL string + jwksURL string endpoint oauth2.Endpoint scopes []string mapUser func(UserData, []byte) token.User // map info from InfoURL to User conf oauth2.Config + keyfunc jwt.Keyfunc + kfLock *sync.Mutex } // Params to make initialized and ready to use provider @@ -39,6 +48,7 @@ type Params struct { Csecret string Issuer string AvatarSaver AvatarSaver + UseOpenID bool // switch to OpenID flow, load user from an ID token instead of userinfo Port int // relevant for providers supporting port customization, for example dev oauth2 } @@ -69,6 +79,14 @@ func initOauth2Handler(p Params, service Oauth2Handler) Oauth2Handler { Endpoint: service.endpoint, } + if p.UseOpenID { + service.kfLock = &sync.Mutex{} + err := service.tryInitJWKSKeyfunc() + if err != nil { + p.Logf("[ERROR] failed to load JWT keys to enable OpenID, will retry on token request: %s", err) + } + } + p.Logf("[DEBUG] created %s oauth2, id=%s, redir=%s, endpoint=%s", service.name, service.Cid, service.makeRedirURL("/{route}/"+service.name+"/"), service.endpoint) return service @@ -160,32 +178,26 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) { } client := p.conf.Client(context.Background(), tok) - uinfo, err := client.Get(p.infoURL) - if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusServiceUnavailable, err, "failed to get client info") - return + + var u token.User + var userData UserData + var rawUserData []byte + + if p.UseOpenID { + userData, rawUserData, err = p.loadUserFromIDToken(tok) } - defer func() { - if e := uinfo.Body.Close(); e != nil { - p.Logf("[WARN] failed to close response body, %s", e) - } - }() + if !p.UseOpenID { + userData, rawUserData, err = p.loadUserFromEndpoint(client) + } - data, err := io.ReadAll(uinfo.Body) if err != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to read user info") + rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to load user data") return } - jData := map[string]interface{}{} - if e := json.Unmarshal(data, &jData); e != nil { - rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info") - return - } - p.Logf("[DEBUG] got raw user info %+v", jData) + u = p.mapUser(userData, rawUserData) - u := p.mapUser(jData, data) if oauthClaims.NoAva { u.Picture = "" // reset picture on no avatar request } @@ -235,9 +247,108 @@ func (p Oauth2Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { p.JwtService.Reset(w) } +func (p Oauth2Handler) loadUserFromIDToken(tok *oauth2.Token) (UserData, []byte, error) { + idToken, ok := tok.Extra("id_token").(string) + if !ok || idToken == "" { + return nil, nil, fmt.Errorf("id_token not found") + } + + if p.keyfunc == nil { + err := p.tryInitJWKSKeyfunc() + if err != nil { + return nil, nil, errors.Wrap(err, "can't load JWKS keys") + } + } + + claims := jwt.MapClaims{} + parser := jwt.Parser{ + // claims validation is not considering clock skew and randomly failing with iat validation + // nbf and exp are validated below + SkipClaimsValidation: true, + } + + parsedIDToken, err := parser.ParseWithClaims(idToken, &claims, p.keyfunc) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to parse id token") + } + + if !parsedIDToken.Valid { + return nil, nil, fmt.Errorf("invalid id token") + } + + now := time.Now().Add(clockSkew).Unix() + if !claims.VerifyExpiresAt(now, false) { + return nil, nil, fmt.Errorf("id token expired") + } + + if !claims.VerifyNotBefore(now, false) { + return nil, nil, fmt.Errorf("id token is not yet valid") + } + + return UserData(claims), []byte(idToken), nil +} + +func (p Oauth2Handler) loadUserFromEndpoint(client *http.Client) (UserData, []byte, error) { + uinfo, err := client.Get(p.infoURL) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to get client info") + } + + defer func() { + if e := uinfo.Body.Close(); e != nil { + p.Logf("[WARN] failed to close response body, %s", e) + } + }() + + data, err := io.ReadAll(uinfo.Body) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to read user info") + } + + jData := map[string]interface{}{} + if e := json.Unmarshal(data, &jData); e != nil { + return nil, nil, errors.Wrap(e, "failed to unmarshal user info") + } + p.Logf("[DEBUG] got raw user info %+v", jData) + + return jData, data, nil +} + func (p Oauth2Handler) makeRedirURL(path string) string { elems := strings.Split(path, "/") newPath := strings.Join(elems[:len(elems)-1], "/") return strings.TrimSuffix(p.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix } + +func (p *Oauth2Handler) tryInitJWKSKeyfunc() error { + p.kfLock.Lock() + defer p.kfLock.Unlock() + if p.keyfunc != nil { + return nil + } + + kf, err := keyfunc.Get(p.jwksURL, keyfunc.Options{ + Client: http.DefaultClient, + Ctx: context.Background(), + RefreshUnknownKID: true, // to support key rotation, re-load keys if KID is unknown + RefreshRateLimit: 1 * time.Minute, // but no often than once per minute + }) + + if err != nil { + return err + } + + p.keyfunc = func(t *jwt.Token) (interface{}, error) { + // only to pass kid across, to manage jwt v3 vs v4 compatibility + v4token := jwtv4.Token{ + Header: map[string]interface{}{ + "kid": t.Header["kid"], + }, + } + + return kf.Keyfunc(&v4token) + } + + return nil +} diff --git a/provider/oauth2_test.go b/provider/oauth2_test.go index 42fe5c72..c804f99a 100644 --- a/provider/oauth2_test.go +++ b/provider/oauth2_test.go @@ -218,7 +218,6 @@ func TestMakeRedirURL(t *testing.T) { } func prepOauth2Test(t *testing.T, loginPort, authPort int) func() { - provider := Oauth2Handler{ name: "mock", endpoint: oauth2.Endpoint{ diff --git a/provider/openid_test.go b/provider/openid_test.go new file mode 100644 index 00000000..d6dc4e56 --- /dev/null +++ b/provider/openid_test.go @@ -0,0 +1,102 @@ +package provider_test + +import ( + "context" + "fmt" + "github.com/go-pkgz/auth" + "github.com/go-pkgz/auth/avatar" + "github.com/go-pkgz/auth/logger" + "github.com/go-pkgz/auth/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "testing" + "time" +) + +func TestNewOpenID(t *testing.T) { + testSrvPort := 9091 + devPort := 9092 + + expectedTestUserSub := fmt.Sprintf("test-user-%d", devPort) + svc := auth.NewService(auth.Opts{ + SecretReader: token.SecretFunc(func(aud string) (string, error) { + return "some-signing-key", nil + }), + Logger: logger.Std, + AvatarStore: avatar.NewNoOp(), + URL: fmt.Sprintf("http://127.0.0.1:%d", testSrvPort), + }) + + svc.AddDevOpenIDProvider(devPort) + devAuth, err := svc.DevAuth() + require.NoError(t, err) + + devAuth.Automatic = true + devAuth.CustomizeIDTokenFn = func(m map[string]interface{}) map[string]interface{} { + m["sub"] = expectedTestUserSub + + now := time.Now().Add(1 * time.Second) // simulate clock difference + m["iat"] = now.Unix() + m["nbf"] = now.Unix() + m["exp"] = now.Add(1 * time.Minute).Unix() + return m + } + + go devAuth.Run(context.Background()) + defer devAuth.Shutdown() + + authHandler, _ := svc.Handlers() + server := httptest.NewUnstartedServer(authHandler) + server.Listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", testSrvPort)) + require.NoError(t, err) + server.Start() + + defer server.Close() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + + client := http.Client{ + Jar: jar, + } + + require.NoError(t, waitFor(fmt.Sprintf("127.0.0.1:%d", testSrvPort))) + require.NoError(t, waitFor(fmt.Sprintf("127.0.0.1:%d", devPort))) + + resp, err := client.Get(server.URL + "/auth/dev/login") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + cookies := resp.Cookies() + var cookie *http.Cookie + for _, c := range cookies { + if c.Name == "JWT" { + cookie = c + break + } + } + + require.NotNil(t, cookie) + claims, err := devAuth.Provider.JwtService.Parse(cookie.Value) + require.NoError(t, err) + + // check user details are from the ID token + assert.Equal(t, expectedTestUserSub, claims.User.ID) +} + +func waitFor(host string) error { + for i := 1; i < 20; i++ { + time.Sleep(time.Duration(i*10) * time.Millisecond) + + dial, err := net.Dial("tcp", host) + if err == nil { + return dial.Close() + } + } + + return fmt.Errorf("timeout waiting for %s", host) +}