satellite/oidc: add integration test
This change adds an integration test that performs an OAuth workflow and verifies the OIDC endpoints are functioning as expected. Change-Id: I18a8968b4f0385a1e4de6784dee68e1b51df86f7
This commit is contained in:
parent
0a298778be
commit
98f4fae02c
2
go.mod
2
go.mod
@ -43,6 +43,7 @@ require (
|
||||
go.uber.org/zap v1.16.0
|
||||
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1
|
||||
@ -118,7 +119,6 @@ require (
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/mod v0.4.2 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect
|
||||
golang.org/x/text v0.3.6 // indirect
|
||||
golang.org/x/tools v0.1.1 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
|
@ -288,8 +288,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
|
||||
analyticsRouter.HandleFunc("/event", analyticsController.EventTriggered).Methods(http.MethodPost)
|
||||
|
||||
if server.config.StaticDir != "" {
|
||||
oidc := oidc.NewEndpoint(server.config.ExternalAddress, oidcService, service, server.config.OauthCodeExpiry,
|
||||
server.config.OauthAccessTokenExpiry, server.config.OauthRefreshTokenExpiry)
|
||||
oidc := oidc.NewEndpoint(server.config.ExternalAddress, logger, oidcService, service,
|
||||
server.config.OauthCodeExpiry, server.config.OauthAccessTokenExpiry, server.config.OauthRefreshTokenExpiry)
|
||||
|
||||
router.HandleFunc("/.well-known/openid-configuration", oidc.WellKnownConfiguration)
|
||||
router.Handle("/oauth/v2/authorize", server.withAuth(http.HandlerFunc(oidc.AuthorizeUser))).Methods(http.MethodPost)
|
||||
|
@ -4,7 +4,6 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -14,6 +13,7 @@ import (
|
||||
"github.com/go-oauth2/oauth2/v4/manage"
|
||||
"github.com/go-oauth2/oauth2/v4/server"
|
||||
"github.com/spacemonkeygo/monkit/v3"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/common/uuid"
|
||||
"storj.io/storj/satellite/console"
|
||||
@ -24,7 +24,11 @@ var (
|
||||
)
|
||||
|
||||
// NewEndpoint constructs an OpenID identity provider.
|
||||
func NewEndpoint(externalAddress string, oidcService *Service, service *console.Service, codeExpiry, accessTokenExpiry, refreshTokenExpiry time.Duration) *Endpoint {
|
||||
func NewEndpoint(
|
||||
externalAddress string, log *zap.Logger,
|
||||
oidcService *Service, service *console.Service,
|
||||
codeExpiry, accessTokenExpiry, refreshTokenExpiry time.Duration,
|
||||
) *Endpoint {
|
||||
manager := manage.NewManager()
|
||||
|
||||
tokenStore := oidcService.TokenStore()
|
||||
@ -58,6 +62,7 @@ func NewEndpoint(externalAddress string, oidcService *Service, service *console.
|
||||
tokenStore: tokenStore,
|
||||
service: service,
|
||||
server: svr,
|
||||
log: log,
|
||||
config: ProviderConfig{
|
||||
Issuer: externalAddress,
|
||||
AuthURL: externalAddress + "oauth/v2/authorize",
|
||||
@ -75,6 +80,7 @@ type Endpoint struct {
|
||||
tokenStore oauth2.TokenStore
|
||||
service *console.Service
|
||||
server *server.Server
|
||||
log *zap.Logger
|
||||
config ProviderConfig
|
||||
}
|
||||
|
||||
@ -84,12 +90,11 @@ func (e *Endpoint) WellKnownConfiguration(w http.ResponseWriter, r *http.Request
|
||||
var err error
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
data, err := json.Marshal(e.config)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = json.NewEncoder(w).Encode(e.config)
|
||||
if err != nil {
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
} else {
|
||||
http.ServeContent(w, r, "", time.Now(), bytes.NewReader(data))
|
||||
e.log.Error("failed to encode oidc config", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,7 +107,7 @@ func (e *Endpoint) AuthorizeUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err = e.server.HandleAuthorizeRequest(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
e.log.Error("failed to authorize user", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,7 +119,7 @@ func (e *Endpoint) Tokens(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err = e.server.HandleTokenRequest(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
e.log.Error("failed to exchange for token", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,12 +170,11 @@ func (e *Endpoint) UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
userInfo.Email = user.Email
|
||||
userInfo.EmailVerified = true
|
||||
|
||||
data, err := json.Marshal(userInfo)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = json.NewEncoder(w).Encode(userInfo)
|
||||
if err != nil {
|
||||
http.Error(w, "", http.StatusInternalServerError)
|
||||
} else {
|
||||
http.ServeContent(w, r, "", time.Now(), bytes.NewReader(data))
|
||||
e.log.Error("failed to encode user info", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
334
satellite/oidc/integration_test.go
Normal file
334
satellite/oidc/integration_test.go
Normal file
@ -0,0 +1,334 @@
|
||||
// Copyright (C) 2022 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"storj.io/common/grant"
|
||||
"storj.io/common/macaroon"
|
||||
"storj.io/common/storj"
|
||||
"storj.io/common/testcontext"
|
||||
"storj.io/common/uuid"
|
||||
"storj.io/storj/private/testplanet"
|
||||
"storj.io/storj/satellite"
|
||||
"storj.io/storj/satellite/console"
|
||||
"storj.io/storj/satellite/console/consoleauth"
|
||||
"storj.io/storj/satellite/oidc"
|
||||
"storj.io/uplink"
|
||||
)
|
||||
|
||||
func send(t *testing.T, body io.Reader, response interface{}, status int, parts ...string) {
|
||||
for len(parts) < 4 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
|
||||
method := parts[1]
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, parts[0], body)
|
||||
require.NoError(t, err)
|
||||
|
||||
auth := parts[2]
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "_tokenKey",
|
||||
Value: auth,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
contentType := parts[3]
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
} else if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
require.Equal(t, status, resp.StatusCode)
|
||||
|
||||
if response != nil {
|
||||
data, err := ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = json.Unmarshal(data, response)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDC(t *testing.T) {
|
||||
id, err := uuid.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
userID, err := uuid.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
testplanet.Run(t, testplanet.Config{
|
||||
SatelliteCount: 1, StorageNodeCount: 1, UplinkCount: 1,
|
||||
Reconfigure: testplanet.Reconfigure{
|
||||
Satellite: func(_ *zap.Logger, _ int, config *satellite.Config) {
|
||||
config.Admin.Address = "127.0.0.1:0"
|
||||
},
|
||||
},
|
||||
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||
sat := planet.Satellites[0]
|
||||
|
||||
adminAddr := sat.Admin.Admin.Listener.Addr().String()
|
||||
consoleAddr := sat.API.Console.Listener.Addr().String()
|
||||
|
||||
issuer := "http://" + consoleAddr + "/"
|
||||
authEndpoint := "http://" + consoleAddr + "/oauth/v2/authorize"
|
||||
tokenEndpoint := "http://" + consoleAddr + "/oauth/v2/tokens"
|
||||
userinfoEndpoint := "http://" + consoleAddr + "/oauth/v2/userinfo"
|
||||
|
||||
// Setup test user
|
||||
|
||||
regToken, err := sat.API.Console.Service.CreateRegToken(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := sat.API.Console.Service.CreateUser(ctx, console.CreateUser{
|
||||
FullName: "User",
|
||||
Email: "u@mail.test",
|
||||
Password: "123a123",
|
||||
}, regToken.Secret)
|
||||
require.NoError(t, err)
|
||||
|
||||
activationToken, err := sat.API.Console.Service.GenerateActivationToken(ctx, user.ID, user.Email)
|
||||
require.NoError(t, err)
|
||||
|
||||
consoleToken, err := sat.API.Console.Service.ActivateAccount(ctx, activationToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up a test project and bucket
|
||||
|
||||
authed := console.WithAuth(ctx, console.Authorization{
|
||||
User: *user,
|
||||
Claims: consoleauth.Claims{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
},
|
||||
})
|
||||
|
||||
project, err := sat.API.Console.Service.CreateProject(authed, console.ProjectInfo{
|
||||
Name: "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketID, err := uuid.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
bucket, err := sat.API.Buckets.Service.CreateBucket(authed, storj.Bucket{
|
||||
ID: bucketID,
|
||||
Name: "test",
|
||||
ProjectID: project.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a client that will receive our tokens
|
||||
|
||||
callback, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = callback.Close() }()
|
||||
|
||||
client := oidc.OAuthClient{
|
||||
ID: id,
|
||||
Secret: []byte("badadmin"),
|
||||
UserID: userID,
|
||||
RedirectURL: "http://" + callback.Addr().String(),
|
||||
}
|
||||
|
||||
adminClients := fmt.Sprintf("http://%s/api/oauth/clients", adminAddr)
|
||||
|
||||
{
|
||||
body, err := json.Marshal(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
send(t, bytes.NewReader(body), nil, http.StatusOK, adminClients, http.MethodPost, sat.Config.Console.AuthToken)
|
||||
}
|
||||
|
||||
// Ensure OpenID Connect's well-known configuration endpoint works.
|
||||
|
||||
wellKnownConfig := fmt.Sprintf("http://%s/.well-known/openid-configuration", consoleAddr)
|
||||
|
||||
cfg := oidc.ProviderConfig{}
|
||||
send(t, nil, &cfg, http.StatusOK, wellKnownConfig)
|
||||
|
||||
require.Equal(t, issuer, cfg.Issuer)
|
||||
require.Equal(t, authEndpoint, cfg.AuthURL)
|
||||
require.Equal(t, tokenEndpoint, cfg.TokenURL)
|
||||
require.Equal(t, userinfoEndpoint, cfg.UserInfoURL)
|
||||
|
||||
// While we don't register a GET handler on the server, we need to ensure that the server returns in a 200
|
||||
// request. This effectively delegates handling of the route to the Vue controller in the browser. If the
|
||||
// server issues a redirect, we drop the encryption key in the fragment making it impossible for the client
|
||||
// to encrypt the derived encryption key.
|
||||
send(t, nil, nil, http.StatusOK, authEndpoint+"#fake-encryption-key")
|
||||
|
||||
// Prepare exchange for token
|
||||
|
||||
token := oauth2.Token{}
|
||||
oauth2Config := oauth2.Config{
|
||||
ClientID: client.ID.String(),
|
||||
ClientSecret: string(client.Secret),
|
||||
RedirectURL: client.RedirectURL,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: authEndpoint,
|
||||
TokenURL: tokenEndpoint,
|
||||
},
|
||||
Scopes: []string{
|
||||
"openid",
|
||||
"object:read",
|
||||
"object:write",
|
||||
"object:delete",
|
||||
},
|
||||
}
|
||||
|
||||
state, err := uuid.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up the callback server to receive our single-use code and exchange for our initial set of tokens.
|
||||
|
||||
server := &http.Server{}
|
||||
defer func() { _ = server.Shutdown(ctx) }()
|
||||
|
||||
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
code := q.Get("code")
|
||||
|
||||
require.Equal(t, state.String(), q.Get("state"))
|
||||
require.NotEqual(t, "", code)
|
||||
|
||||
token, err := oauth2Config.Exchange(r.Context(), code)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = json.NewEncoder(w).Encode(token)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
group := errgroup.Group{}
|
||||
group.Go(func() error {
|
||||
return server.Serve(callback)
|
||||
})
|
||||
|
||||
// Mock submitting the consent screen, granting the application the following permissions.
|
||||
|
||||
scope := fmt.Sprintf("project:%s bucket:%s cubbyhole:cyphertext object:list object:read object:write object:delete",
|
||||
project.ID.String(), bucket.Name)
|
||||
|
||||
consent := url.Values{}
|
||||
consent.Set("redirect_uri", client.RedirectURL)
|
||||
consent.Set("client_id", client.ID.String())
|
||||
consent.Set("response_type", "code")
|
||||
consent.Set("state", state.String())
|
||||
consent.Set("scope", scope)
|
||||
|
||||
{
|
||||
body := strings.NewReader(consent.Encode())
|
||||
send(t, body, &token, http.StatusOK, authEndpoint, http.MethodPost, consoleToken, "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
require.Equal(t, "Bearer", token.TokenType)
|
||||
require.NotEqual(t, "", token.AccessToken)
|
||||
require.NotEqual(t, "", token.RefreshToken)
|
||||
|
||||
// Refresh the tokens.
|
||||
|
||||
refresh := url.Values{}
|
||||
refresh.Set("grant_type", "refresh_token")
|
||||
refresh.Set("refresh_token", token.RefreshToken)
|
||||
|
||||
refreshed := oauth2.Token{}
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(client.ID.String() + ":" + string(client.Secret)))
|
||||
|
||||
{
|
||||
body := strings.NewReader(refresh.Encode())
|
||||
send(t, body, &refreshed, http.StatusOK, tokenEndpoint, http.MethodPost, "Basic "+auth, "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
require.Equal(t, token.RefreshToken, refreshed.RefreshToken)
|
||||
require.NotEqual(t, token.AccessToken, refreshed.AccessToken)
|
||||
|
||||
// Fetch UserInfo
|
||||
|
||||
info := oidc.UserInfo{}
|
||||
send(t, nil, &info, http.StatusOK, userinfoEndpoint, http.MethodGet, "Bearer "+token.AccessToken)
|
||||
|
||||
require.Equal(t, "cyphertext", info.Cubbyhole)
|
||||
|
||||
// Use token with uplink
|
||||
|
||||
apiKey, err := macaroon.ParseAPIKey(token.AccessToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// in practice, you should decrypt the cubbyhole and pass it here
|
||||
key, err := storj.NewKey([]byte(info.Cubbyhole))
|
||||
require.NoError(t, err)
|
||||
|
||||
encAccess := grant.NewEncryptionAccessWithDefaultKey(key)
|
||||
encAccess.SetDefaultKey(key)
|
||||
encAccess.SetDefaultPathCipher(storj.EncAESGCM)
|
||||
|
||||
accessGrant, err := (&grant.Access{
|
||||
SatelliteAddress: sat.NodeURL().String(),
|
||||
APIKey: apiKey,
|
||||
EncAccess: encAccess,
|
||||
}).Serialize()
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
access, err := uplink.ParseAccess(accessGrant)
|
||||
require.NoError(t, err)
|
||||
|
||||
proj, err := uplink.OpenProject(ctx, access)
|
||||
require.NoError(t, err)
|
||||
|
||||
upload, err := proj.UploadObject(ctx, bucket.Name, "testing/1/2/3", &uplink.UploadOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = upload.Abort() }()
|
||||
|
||||
_, err = upload.Write([]byte("hello world!"))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = upload.Commit()
|
||||
require.NoError(t, err)
|
||||
|
||||
download, err := proj.DownloadObject(ctx, bucket.Name, "testing/1/2/3", &uplink.DownloadOptions{
|
||||
Length: -1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = download.Close() }()
|
||||
|
||||
content, err := io.ReadAll(download)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "hello world!", string(content))
|
||||
})
|
||||
}
|
@ -23,6 +23,9 @@ type UUIDAuthorizeGenerate struct{}
|
||||
|
||||
// Token returns a new authorization code.
|
||||
func (a *UUIDAuthorizeGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic) (string, error) {
|
||||
var err error
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
code, err := uuid.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -44,6 +47,9 @@ type GenerateService interface {
|
||||
}
|
||||
|
||||
func (a *MacaroonAccessGenerate) apiKeyForProject(ctx context.Context, data *oauth2.GenerateBasic, project string) (*macaroon.APIKey, error) {
|
||||
var err error
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
userID, err := uuid.FromString(data.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -99,6 +105,8 @@ func (a *MacaroonAccessGenerate) apiKeyForProject(ctx context.Context, data *oau
|
||||
// In OAuth2.0, access_tokens are short-lived tokens that authorize operations to be performed on behalf of an end user.
|
||||
// refresh_tokens are longer lived tokens that allow you to obtain new authorization tokens.
|
||||
func (a *MacaroonAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (access, refresh string, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
||||
var apiKey *macaroon.APIKey
|
||||
|
||||
if priorRefresh := data.TokenInfo.GetRefresh(); isGenRefresh && priorRefresh != "" {
|
||||
|
Loading…
Reference in New Issue
Block a user