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:
Mya 2022-02-08 15:28:11 -06:00 committed by mya
parent 0a298778be
commit 98f4fae02c
5 changed files with 361 additions and 15 deletions

2
go.mod
View File

@ -43,6 +43,7 @@ require (
go.uber.org/zap v1.16.0 go.uber.org/zap v1.16.0
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 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/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 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/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
golang.org/x/mod v0.4.2 // 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/text v0.3.6 // indirect
golang.org/x/tools v0.1.1 // indirect golang.org/x/tools v0.1.1 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect

View File

@ -288,8 +288,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
analyticsRouter.HandleFunc("/event", analyticsController.EventTriggered).Methods(http.MethodPost) analyticsRouter.HandleFunc("/event", analyticsController.EventTriggered).Methods(http.MethodPost)
if server.config.StaticDir != "" { if server.config.StaticDir != "" {
oidc := oidc.NewEndpoint(server.config.ExternalAddress, oidcService, service, server.config.OauthCodeExpiry, oidc := oidc.NewEndpoint(server.config.ExternalAddress, logger, oidcService, service,
server.config.OauthAccessTokenExpiry, server.config.OauthRefreshTokenExpiry) server.config.OauthCodeExpiry, server.config.OauthAccessTokenExpiry, server.config.OauthRefreshTokenExpiry)
router.HandleFunc("/.well-known/openid-configuration", oidc.WellKnownConfiguration) router.HandleFunc("/.well-known/openid-configuration", oidc.WellKnownConfiguration)
router.Handle("/oauth/v2/authorize", server.withAuth(http.HandlerFunc(oidc.AuthorizeUser))).Methods(http.MethodPost) router.Handle("/oauth/v2/authorize", server.withAuth(http.HandlerFunc(oidc.AuthorizeUser))).Methods(http.MethodPost)

View File

@ -4,7 +4,6 @@
package oidc package oidc
import ( import (
"bytes"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@ -14,6 +13,7 @@ import (
"github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server" "github.com/go-oauth2/oauth2/v4/server"
"github.com/spacemonkeygo/monkit/v3" "github.com/spacemonkeygo/monkit/v3"
"go.uber.org/zap"
"storj.io/common/uuid" "storj.io/common/uuid"
"storj.io/storj/satellite/console" "storj.io/storj/satellite/console"
@ -24,7 +24,11 @@ var (
) )
// NewEndpoint constructs an OpenID identity provider. // 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() manager := manage.NewManager()
tokenStore := oidcService.TokenStore() tokenStore := oidcService.TokenStore()
@ -58,6 +62,7 @@ func NewEndpoint(externalAddress string, oidcService *Service, service *console.
tokenStore: tokenStore, tokenStore: tokenStore,
service: service, service: service,
server: svr, server: svr,
log: log,
config: ProviderConfig{ config: ProviderConfig{
Issuer: externalAddress, Issuer: externalAddress,
AuthURL: externalAddress + "oauth/v2/authorize", AuthURL: externalAddress + "oauth/v2/authorize",
@ -75,6 +80,7 @@ type Endpoint struct {
tokenStore oauth2.TokenStore tokenStore oauth2.TokenStore
service *console.Service service *console.Service
server *server.Server server *server.Server
log *zap.Logger
config ProviderConfig config ProviderConfig
} }
@ -84,12 +90,11 @@ func (e *Endpoint) WellKnownConfiguration(w http.ResponseWriter, r *http.Request
var err error var err error
defer mon.Task()(&ctx)(&err) 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 { if err != nil {
http.Error(w, "", http.StatusInternalServerError) e.log.Error("failed to encode oidc config", zap.Error(err))
} else {
http.ServeContent(w, r, "", time.Now(), bytes.NewReader(data))
} }
} }
@ -102,7 +107,7 @@ func (e *Endpoint) AuthorizeUser(w http.ResponseWriter, r *http.Request) {
err = e.server.HandleAuthorizeRequest(w, r) err = e.server.HandleAuthorizeRequest(w, r)
if err != nil { 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) err = e.server.HandleTokenRequest(w, r)
if err != nil { 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.Email = user.Email
userInfo.EmailVerified = true userInfo.EmailVerified = true
data, err := json.Marshal(userInfo) w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(userInfo)
if err != nil { if err != nil {
http.Error(w, "", http.StatusInternalServerError) e.log.Error("failed to encode user info", zap.Error(err))
} else {
http.ServeContent(w, r, "", time.Now(), bytes.NewReader(data))
} }
} }

View 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))
})
}

View File

@ -23,6 +23,9 @@ type UUIDAuthorizeGenerate struct{}
// Token returns a new authorization code. // Token returns a new authorization code.
func (a *UUIDAuthorizeGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic) (string, error) { func (a *UUIDAuthorizeGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic) (string, error) {
var err error
defer mon.Task()(&ctx)(&err)
code, err := uuid.New() code, err := uuid.New()
if err != nil { if err != nil {
return "", err return "", err
@ -44,6 +47,9 @@ type GenerateService interface {
} }
func (a *MacaroonAccessGenerate) apiKeyForProject(ctx context.Context, data *oauth2.GenerateBasic, project string) (*macaroon.APIKey, error) { 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) userID, err := uuid.FromString(data.UserID)
if err != nil { if err != nil {
return nil, err 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. // 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. // 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) { 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 var apiKey *macaroon.APIKey
if priorRefresh := data.TokenInfo.GetRefresh(); isGenRefresh && priorRefresh != "" { if priorRefresh := data.TokenInfo.GetRefresh(); isGenRefresh && priorRefresh != "" {