From 01eb0fdf5d941b645e80233a39e46b6313efd560 Mon Sep 17 00:00:00 2001 From: sispeo <42068883+fperot74@users.noreply.github.com> Date: Wed, 22 Jan 2020 11:13:40 +0100 Subject: [PATCH] [CLOUDTRUST-2107] Add a method to retrieve OIDC token --- Gopkg.lock | 14 ++++--- oidc_connect.go | 99 ++++++++++++++++++++++++++++++++++++++++++++ oidc_connect_test.go | 98 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 oidc_connect.go create mode 100644 oidc_connect_test.go diff --git a/Gopkg.lock b/Gopkg.lock index b3ca125..fae3c63 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -3,14 +3,15 @@ [[projects]] branch = "master" - digest = "1:c3e6e91aafe6e3a12e3669b77f8fd608ddf8e61a727858ce50811daabc9600ea" + digest = "1:9cd919baadd8b77a9af99f59b3240f7296f58acd88c7ceb4fe812649be8ae176" name = "github.com/cloudtrust/common-service" packages = [ ".", "errors", + "log", ] pruneopts = "" - revision = "bda3eb6af01813931780dc33b49aabd0f878be19" + revision = "739cff91a05f8dbcf3a0c4e6e57b95089f2a052b" [[projects]] digest = "1:379d34d9efc755fab444199f007819fe99718640f9ccfbdd3f0430340bb02b07" @@ -37,17 +38,17 @@ version = "v2.0.0" [[projects]] - digest = "1:183b1cb81b770d8033281c5629a4847a2ed7614068bb33c5a9a159d1226b23f0" + digest = "1:48e65aaf8ce34ffb3e8d56daa9417826db162afbc2040705db331e9a2e9eebe3" name = "github.com/go-kit/kit" packages = [ "endpoint", "log", - "transport", + "log/level", "transport/http", ] pruneopts = "" - revision = "150a65a7ec6156b4b640c1fd55f26fd3d475d656" - version = "v0.9.0" + revision = "12210fb6ace19e0496167bb3e667dcd91fa9f69b" + version = "v0.8.0" [[projects]] digest = "1:aa9a6ccd5fd7d29804a27cb0666bc4ac5eb4b73439b7edd54f4b377e5ef8bb47" @@ -242,6 +243,7 @@ input-imports = [ "github.com/cloudtrust/common-service", "github.com/cloudtrust/common-service/errors", + "github.com/cloudtrust/common-service/log", "github.com/coreos/go-oidc", "github.com/gbrlsnchs/jwt", "github.com/go-kit/kit/transport/http", diff --git a/oidc_connect.go b/oidc_connect.go new file mode 100644 index 0000000..08a6105 --- /dev/null +++ b/oidc_connect.go @@ -0,0 +1,99 @@ +package keycloak + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + errorhandler "github.com/cloudtrust/common-service/errors" + "github.com/cloudtrust/common-service/log" +) + +// OidcTokenProvider provides OIDC tokens +type OidcTokenProvider interface { + ProvideToken(ctx context.Context) (string, error) +} + +type oidcToken struct { + AccessToken string `json:"access_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + RefreshExpiresIn int64 `json:"refresh_expires_in,omitempty"` + TokenType string `json:"token_type,omitempty"` + NotBeforePolicy int `json:"not-before-policy,omitempty"` + SessionState string `json:"session_state,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type oidcTokenProvider struct { + timeout time.Duration + tokenURL string + reqBody string + logger log.Logger + oidcToken oidcToken + validUntil int64 +} + +const ( + // Max processing delay: let's assume that the user of OidcTokenProvider will have a maximum of 5 seconds to use the provided OIDC token + maxProcessingDelay = int64(5) +) + +// NewOidcTokenProvider creates an OidcTokenProvider +func NewOidcTokenProvider(config Config, realm, username, password, clientID string, logger log.Logger) OidcTokenProvider { + var tokenURL = fmt.Sprintf("%s/auth/realms/%s/protocol/openid-connect/token", config.AddrAPI, realm) + // If needed, can add &client_secret={secret} + var body = fmt.Sprintf("grant_type=password&client_id=%s&username=%s&password=%s", + url.QueryEscape(clientID), url.QueryEscape(username), url.QueryEscape(password)) + + return &oidcTokenProvider{ + timeout: config.Timeout, + tokenURL: tokenURL, + reqBody: body, + logger: logger, + } +} + +func (o *oidcTokenProvider) ProvideToken(ctx context.Context) (string, error) { + if o.validUntil+maxProcessingDelay > time.Now().Unix() { + return o.oidcToken.AccessToken, nil + } + + var mimeType = "application/x-www-form-urlencoded" + var httpClient = http.Client{ + Timeout: o.timeout, + } + var resp, err = httpClient.Post(o.tokenURL, mimeType, strings.NewReader(o.reqBody)) + if err != nil { + o.logger.Warn(ctx, "msg", err.Error()) + return "", errorhandler.CreateInternalServerError("unexpected.httpResponse") + } + if err == nil && resp.StatusCode == http.StatusUnauthorized { + o.logger.Warn(ctx, "msg", "Technical user credentials are invalid") + return "", errorhandler.Error{ + Status: http.StatusUnauthorized, + Message: errorhandler.GetEmitter() + ".unauthorized", + } + } + if resp.StatusCode >= 400 || resp.Body == http.NoBody || resp.Body == nil { + o.logger.Warn(ctx, "msg", fmt.Sprintf("Unexpected behavior: unexpected http status (%d) or response has no body", resp.StatusCode)) + return "", errorhandler.CreateInternalServerError("unexpected.httpResponse") + } + + buf := new(bytes.Buffer) + buf.ReadFrom(resp.Body) + + err = json.Unmarshal(buf.Bytes(), &o.oidcToken) + if err != nil { + o.logger.Warn(ctx, "msg", fmt.Sprintf("Can't deserialize token. JSON: %s", buf.String())) + return "", errorhandler.CreateInternalServerError("unexpected.oidcToken") + } + o.validUntil = time.Now().Unix() + o.oidcToken.ExpiresIn + + return o.oidcToken.AccessToken, nil +} diff --git a/oidc_connect_test.go b/oidc_connect_test.go new file mode 100644 index 0000000..d94870c --- /dev/null +++ b/oidc_connect_test.go @@ -0,0 +1,98 @@ +package keycloak + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/cloudtrust/common-service/log" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +type TestResponse struct { + StatusCode int + NoBody bool + ResponseBody string +} + +func (t *TestResponse) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(t.StatusCode) + if !t.NoBody { + w.Write([]byte(t.ResponseBody)) + } + time.Sleep(20 * time.Millisecond) +} + +func TestCreateToken(t *testing.T) { + var oidcToken = oidcToken{ + AccessToken: `eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJZRTUtNUpBb2NOcG5zeEpEaGRyYVdyWlVCZTBMR2xfanNILUtnb1EwWi1FIn0.eyJqdGkiOiIxYjJkZDY2NS01ZGE1LTRiMzAtODY0MS0wNWQ4ZTk0NTQ2ZWQiLCJleHAiOjE1NzkyMDQ0ODYsIm5iZiI6MCwiaWF0IjoxNTc5MTY4NDg2LCJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjgwODAvYXV0aC9yZWFsbXMvbWFzdGVyIiwiYXVkIjpbInBhc3NmbG93LXJlYWxtIiwibXlfcmVhbG0tcmVhbG0iLCJDbG91ZHRydXN0LXJlYWxtIiwibWFzdGVyLXJlYWxtIiwiYWNjb3VudCJdLCJzdWIiOiI3OTU5MjhjMy03N2Y1LTRmMjQtOTI0NC02NzBkMGJmMDJhMmQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJhZG1pbi1jbGkiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI5ZTQ5NDA1MS1kZGQ1LTRhODctYTczZC1hOWU5YjMwYmFlZGEiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImNyZWF0ZS1yZWFsbSIsIm9mZmxpbmVfYWNjZXNzIiwiYWRtaW4iLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7InBhc3NmbG93LXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJteV9yZWFsbS1yZWFsbSI6eyJyb2xlcyI6WyJ2aWV3LXJlYWxtIiwidmlldy1pZGVudGl0eS1wcm92aWRlcnMiLCJtYW5hZ2UtaWRlbnRpdHktcHJvdmlkZXJzIiwiaW1wZXJzb25hdGlvbiIsImNyZWF0ZS1jbGllbnQiLCJtYW5hZ2UtdXNlcnMiLCJxdWVyeS1yZWFsbXMiLCJ2aWV3LWF1dGhvcml6YXRpb24iLCJxdWVyeS1jbGllbnRzIiwicXVlcnktdXNlcnMiLCJtYW5hZ2UtZXZlbnRzIiwibWFuYWdlLXJlYWxtIiwidmlldy1ldmVudHMiLCJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwibWFuYWdlLWF1dGhvcml6YXRpb24iLCJtYW5hZ2UtY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyJdfSwiQ2xvdWR0cnVzdC1yZWFsbSI6eyJyb2xlcyI6WyJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsInZpZXctcmVhbG0iLCJtYW5hZ2UtaWRlbnRpdHktcHJvdmlkZXJzIiwiaW1wZXJzb25hdGlvbiIsImNyZWF0ZS1jbGllbnQiLCJtYW5hZ2UtdXNlcnMiLCJxdWVyeS1yZWFsbXMiLCJ2aWV3LWF1dGhvcml6YXRpb24iLCJxdWVyeS1jbGllbnRzIiwicXVlcnktdXNlcnMiLCJtYW5hZ2UtZXZlbnRzIiwibWFuYWdlLXJlYWxtIiwidmlldy1ldmVudHMiLCJ2aWV3LXVzZXJzIiwidmlldy1jbGllbnRzIiwibWFuYWdlLWF1dGhvcml6YXRpb24iLCJtYW5hZ2UtY2xpZW50cyIsInF1ZXJ5LWdyb3VwcyJdfSwibWFzdGVyLXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUgZ3JvdXBzIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJncm91cHMiOlsiL3RvZV9hZG1pbmlzdHJhdG9yIl0sInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.GrPUDdQUID0S38ZSwVqarAuSDXrl5cJju3uq32y6bNGPK9a8jcHBP5FEfMcZ3vieQPtWFeySMycwTEcH6x6lc-9bj1w5veL4yyTA1zk_ERPshfiobk0u94vuljnoz-PW7JvBLOy47Bk5cP9pHPbJMPY0kOFHTpXZHd6KwfcE_X8gizLw4rDhIpK1NEtABQVzUNvP9fDZOm2I1PHJbl0odRE7EFu9Xh5ya8DaUQ2RKUb0E5csnA3DYlFdEhtMV1MAKRzqplDzj8zLQ8f8fflzC9_g4vmnDUEKSBxq1f1qKmzm1-XUuqRYTNWHfOtRR9rXrEzn-6fymFcRHIVGW7kgzg`, + ExpiresIn: 3600, + } + var validJSON, _ = json.Marshal(oidcToken) + + r := mux.NewRouter() + r.Handle("/auth/realms/nobody/protocol/openid-connect/token", &TestResponse{StatusCode: http.StatusOK, NoBody: true}) + r.Handle("/auth/realms/invalid/protocol/openid-connect/token", &TestResponse{StatusCode: http.StatusUnauthorized}) + r.Handle("/auth/realms/bad-json/protocol/openid-connect/token", &TestResponse{StatusCode: http.StatusOK, ResponseBody: `{"truncated-`}) + r.Handle("/auth/realms/valid/protocol/openid-connect/token", &TestResponse{StatusCode: http.StatusOK, ResponseBody: string(validJSON)}) + + ts := httptest.NewServer(r) + defer ts.Close() + + t.Run("No body in HTTP response", func(t *testing.T) { + var p = NewOidcTokenProvider(Config{AddrAPI: ts.URL}, "nobody", "user", "passwd", "clientID", log.NewNopLogger()) + var _, err = p.ProvideToken(context.TODO()) + assert.NotNil(t, err) + }) + + t.Run("Invalid credentials", func(t *testing.T) { + var p = NewOidcTokenProvider(Config{AddrAPI: ts.URL}, "invalid", "user", "passwd", "clientID", log.NewNopLogger()) + var _, err = p.ProvideToken(context.TODO()) + assert.NotNil(t, err) + }) + + t.Run("Invalid JSON", func(t *testing.T) { + var p = NewOidcTokenProvider(Config{AddrAPI: ts.URL}, "bad-json", "user", "passwd", "clientID", log.NewNopLogger()) + var _, err = p.ProvideToken(context.TODO()) + assert.NotNil(t, err) + }) + + t.Run("No HTTP response", func(t *testing.T) { + var p = NewOidcTokenProvider(Config{AddrAPI: ts.URL + "0"}, "bad-json", "user", "passwd", "clientID", log.NewNopLogger()) + var _, err = p.ProvideToken(context.TODO()) + assert.NotNil(t, err) + }) + + t.Run("Valid credentials", func(t *testing.T) { + var p = NewOidcTokenProvider(Config{AddrAPI: ts.URL}, "valid", "user", "passwd", "clientID", log.NewNopLogger()) + + var timeStart = time.Now() + + // First call + var token, err = p.ProvideToken(context.TODO()) + assert.Nil(t, err) + assert.NotEqual(t, "", token) + + var timeAfterFirstCall = time.Now() + + // Second call + token, err = p.ProvideToken(context.TODO()) + assert.Nil(t, err) + assert.NotEqual(t, "", token) + + var timeAfterSecondCall = time.Now() + + var withHTTPDuration = int64(20 * time.Millisecond) + var withoutHTTPDuration = int64(5 * time.Millisecond) + var duration1 = timeAfterFirstCall.Sub(timeStart).Nanoseconds() + var duration2 = timeAfterSecondCall.Sub(timeAfterFirstCall).Nanoseconds() + var msg = fmt.Sprintf("Durations: no valid token loaded yet:%d (expected > %d), token not expired:%d (expected < %d)", duration1, withHTTPDuration, duration2, withoutHTTPDuration) + assert.True(t, duration1 > withHTTPDuration, msg) + assert.True(t, duration2 < withoutHTTPDuration, msg) + }) +}