committed by
GitHub
3 changed files with 205 additions and 6 deletions
@ -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 |
||||
|
} |
||||
@ -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) |
||||
|
}) |
||||
|
} |
||||
Loading…
Reference in new issue