From 189b3d6a9acfc5cf14f6099d19697ede8a26882c Mon Sep 17 00:00:00 2001 From: Andrii Holovko Date: Thu, 30 Jun 2022 16:51:18 +0300 Subject: [PATCH] feat: onboard user during gnap oidc flow, add bootstrap handlers Signed-off-by: Andrii Holovko --- cmd/auth-rest/startcmd/start.go | 5 + pkg/restapi/gnap/operations.go | 272 ++++++++++++++- pkg/restapi/gnap/operations_test.go | 423 ++++++++++++++++++++++- pkg/restapi/operation/operations.go | 7 +- pkg/restapi/operation/operations_test.go | 16 +- 5 files changed, 688 insertions(+), 35 deletions(-) diff --git a/cmd/auth-rest/startcmd/start.go b/cmd/auth-rest/startcmd/start.go index 35d7f88..a1f74d6 100644 --- a/cmd/auth-rest/startcmd/start.go +++ b/cmd/auth-rest/startcmd/start.go @@ -525,6 +525,11 @@ func startAuthService(parameters *authRestParameters, srv server) error { CallbackURL: parameters.oidcParams.callbackURL, Providers: parameters.oidcParams.providers, }, + BootstrapConfig: &gnap.BootstrapConfig{ + DocumentSDSVaultURL: parameters.bootstrapParams.documentSDSVaultURL, + KeySDSVaultURL: parameters.bootstrapParams.keySDSVaultURL, + OpsKeyServerURL: parameters.bootstrapParams.opsKeyServerURL, + }, TransientStoreProvider: provider, TLSConfig: &tls.Config{RootCAs: rootCAs}, //nolint:gosec DisableHTTPSigVerify: parameters.gnap.disableHTTPSigVerify, diff --git a/pkg/restapi/gnap/operations.go b/pkg/restapi/gnap/operations.go index 7479adf..4a0e1b5 100644 --- a/pkg/restapi/gnap/operations.go +++ b/pkg/restapi/gnap/operations.go @@ -9,8 +9,12 @@ package gnap import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" "encoding/json" + "errors" "fmt" "html/template" "io/ioutil" @@ -24,9 +28,12 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/google/uuid" "github.com/hyperledger/aries-framework-go/pkg/common/log" + "github.com/hyperledger/aries-framework-go/pkg/doc/jose/jwk" "github.com/hyperledger/aries-framework-go/spi/storage" + "github.com/square/go-jose/v3" "golang.org/x/oauth2" + "github.com/trustbloc/auth/pkg/bootstrap/user" "github.com/trustbloc/auth/pkg/gnap/accesspolicy" "github.com/trustbloc/auth/pkg/gnap/api" "github.com/trustbloc/auth/pkg/gnap/authhandler" @@ -50,6 +57,8 @@ const ( // InteractPath endpoint for GNAP interact. InteractPath = gnapBasePath + "/interact" + bootstrapPath = gnapBasePath + "/bootstrap" + // oidc api handlers. authProvidersPath = "/oidc/providers" oidcLoginPath = "/oidc/login" @@ -64,18 +73,35 @@ const ( txnQueryParam = "txnID" transientStoreName = "gnap_transient" + bootstrapStoreName = "bootstrapdata" // client redirect query params. interactRefQueryParam = "interact_ref" responseHashQueryParam = "hash" + + gnapScheme = "GNAP " ) // TODO: figure out what logic should go in the access policy vs operation handlers. +// BootstrapData is the user's bootstrap data. +type BootstrapData struct { + DocumentSDSVaultURL string `json:"documentSDSURL"` + KeySDSVaultURL string `json:"keySDSURL"` + OpsKeyServerURL string `json:"opsKeyServerURL"` + Data map[string]string `json:"data,omitempty"` +} + +// UpdateBootstrapDataRequest is a request to update bootstrap data. +type UpdateBootstrapDataRequest struct { + Data map[string]string `json:"data"` +} + // Operation defines Auth Server GNAP handlers. type Operation struct { authHandler *authhandler.AuthHandler interactionHandler api.InteractionHandler + introspectHandler common.Introspecter uiEndpoint string closePopupHTML string authProviders []authProvider @@ -86,6 +112,9 @@ type Operation struct { callbackURL string timeout uint64 transientStore storage.Store + bootstrapStore storage.Store + bootstrapConfig *BootstrapConfig + gnapRSClient *gnap.RequestClient } // Config defines configuration for GNAP operations. @@ -101,6 +130,14 @@ type Config struct { TransientStoreProvider storage.Provider TLSConfig *tls.Config DisableHTTPSigVerify bool + BootstrapConfig *BootstrapConfig +} + +// BootstrapConfig holds user bootstrap-related config. +type BootstrapConfig struct { + DocumentSDSVaultURL string + KeySDSVaultURL string + OpsKeyServerURL string } // New creates GNAP operation handler. @@ -127,9 +164,23 @@ func New(config *Config) (*Operation, error) { return nil, err } - transientStore, err := createStore(config.TransientStoreProvider) + transientStore, err := createStore(config.TransientStoreProvider, transientStoreName) + if err != nil { + return nil, err + } + + bootstrapStore, err := createStore(config.StoreProvider, bootstrapStoreName) + if err != nil { + return nil, err + } + + introspectHandler := func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return auth.HandleIntrospection(req, &skipVerify{}) + } + + gnapRSClient, err := createGNAPClient() if err != nil { - return nil, fmt.Errorf("failed to create transient store: %w", err) + return nil, err } return &Operation{ @@ -140,9 +191,13 @@ func New(config *Config) (*Operation, error) { cachedOIDCProviders: make(map[string]oidcProvider), timeout: config.StartupTimeout, transientStore: transientStore, + bootstrapStore: bootstrapStore, tlsConfig: config.TLSConfig, interactionHandler: config.InteractionHandler, closePopupHTML: config.ClosePopupHTML, + bootstrapConfig: config.BootstrapConfig, + introspectHandler: introspectHandler, + gnapRSClient: gnapRSClient, }, nil } @@ -153,14 +208,22 @@ func (o *Operation) GetRESTHandlers() []common.Handler { // TODO add txn_id to url path support.NewHTTPHandler(InteractPath, http.MethodGet, o.interactHandler), support.NewHTTPHandler(AuthContinuePath, http.MethodPost, o.authContinueHandler), - support.NewHTTPHandler(AuthIntrospectPath, http.MethodPost, o.introspectHandler), + support.NewHTTPHandler(AuthIntrospectPath, http.MethodPost, o.authIntrospectHandler), support.NewHTTPHandler(authProvidersPath, http.MethodGet, o.authProvidersHandler), support.NewHTTPHandler(oidcLoginPath, http.MethodGet, o.oidcLoginHandler), support.NewHTTPHandler(oidcCallbackPath, http.MethodGet, o.oidcCallbackHandler), + + support.NewHTTPHandler(bootstrapPath, http.MethodGet, o.getBootstrapDataHandler), + support.NewHTTPHandler(bootstrapPath, http.MethodPost, o.postBootstrapDataHandler), } } +// SetIntrospectHandler sets the GNAP introspection handler for Operation's APIs. +func (o *Operation) SetIntrospectHandler(i common.Introspecter) { + o.introspectHandler = i +} + func (o *Operation) authRequestHandler(w http.ResponseWriter, req *http.Request) { authRequest := &gnap.AuthRequest{} @@ -379,6 +442,16 @@ func (o *Operation) oidcCallbackHandler(w http.ResponseWriter, r *http.Request) return } + _, err = user.NewStore(o.bootstrapStore).Get(claims.Sub) + if errors.Is(err, storage.ErrDataNotFound) { + _, err = o.onboardUser(claims.Sub) + if err != nil { + o.writeErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to onboard new user : %s", err)) + + return + } + } + interactRef, responseHash, clientInteract, err := o.interactionHandler.CompleteInteraction( data.TxnID, &api.ConsentResult{ @@ -482,6 +555,90 @@ func (o *Operation) authContinueHandler(w http.ResponseWriter, req *http.Request o.writeResponse(w, resp) } +func (o *Operation) getBootstrapDataHandler(w http.ResponseWriter, r *http.Request) { + logger.Debugf("handling request") + + subject, proceed := o.subject(w, r) + if !proceed { + return + } + + profile, err := user.NewStore(o.bootstrapStore).Get(subject) + if errors.Is(err, storage.ErrDataNotFound) { + o.writeErrorResponse(w, http.StatusBadRequest, "invalid handle") + + return + } + + if err != nil { + o.writeErrorResponse(w, http.StatusInternalServerError, + fmt.Sprintf("failed to query bootstrap store for handle: %s", err)) + + return + } + + response, err := json.Marshal(&BootstrapData{ + DocumentSDSVaultURL: o.bootstrapConfig.DocumentSDSVaultURL, + KeySDSVaultURL: o.bootstrapConfig.KeySDSVaultURL, + OpsKeyServerURL: o.bootstrapConfig.OpsKeyServerURL, + Data: profile.Data, + }) + if err != nil { + o.writeErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to marshal bootstrap data: %s", err)) + + return + } + + // TODO We should delete the handle from the transient store after writing the response, + // but edge-core store API doesn't have a Delete() operation: https://github.com/trustbloc/edge-core/issues/45 + _, err = w.Write(response) + if err != nil { + logger.Errorf("failed to write bootstrap data to output: %s", err) + } + + logger.Debugf("finished handling request") +} + +func (o *Operation) postBootstrapDataHandler(w http.ResponseWriter, r *http.Request) { + logger.Debugf("handling request") + + subject, proceed := o.subject(w, r) + if !proceed { + return + } + + update := &UpdateBootstrapDataRequest{} + + err := json.NewDecoder(r.Body).Decode(update) + if err != nil { + o.writeErrorResponse(w, http.StatusBadRequest, "failed to decode request: %s", err.Error()) + + return + } + + existing, err := user.NewStore(o.bootstrapStore).Get(subject) + if errors.Is(err, storage.ErrDataNotFound) { + o.writeErrorResponse(w, http.StatusConflict, "associated bootstrap data not found") + + return + } + + if err != nil { + o.writeErrorResponse(w, http.StatusInternalServerError, "failed to query storage: %s", err.Error()) + + return + } + + err = user.NewStore(o.bootstrapStore).Save(merge(existing, update)) + if err != nil { + o.writeErrorResponse(w, http.StatusInternalServerError, "failed to update storage: %s", err.Error()) + + return + } + + logger.Debugf("finished handling request") +} + type skipVerify struct{} // Verify skip request verification when introspecting internally through Go. @@ -492,12 +649,10 @@ func (s skipVerify) Verify(_ *gnap.ClientKey) error { // InternalIntrospectHandler returns a handler that allows the auth server's handlers to perform GNAP introspection // with itself as the AS and RS. func (o *Operation) InternalIntrospectHandler() common.Introspecter { - return func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { - return o.authHandler.HandleIntrospection(req, &skipVerify{}) - } + return o.introspectHandler } -func (o *Operation) introspectHandler(w http.ResponseWriter, req *http.Request) { +func (o *Operation) authIntrospectHandler(w http.ResponseWriter, req *http.Request) { introspectRequest := &gnap.IntrospectRequest{} bodyBytes, err := ioutil.ReadAll(req.Body) @@ -636,11 +791,108 @@ func (o *Operation) initOIDCProvider(providerID string, config *oidcmodel.Provid }, nil } -func createStore(p storage.Provider) (storage.Store, error) { - s, err := p.OpenStore(transientStoreName) +func createStore(p storage.Provider, name string) (storage.Store, error) { + s, err := p.OpenStore(name) if err != nil { - return nil, fmt.Errorf("failed to open store [%s] : %w", transientStoreName, err) + return nil, fmt.Errorf("failed to open store [%s]: %w", name, err) } return s, nil } + +func createGNAPClient() (*gnap.RequestClient, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("creating public key for GNAP RS role: %w", err) + } + + return &gnap.RequestClient{ + IsReference: false, + Key: &gnap.ClientKey{ + Proof: "httpsig", + JWK: jwk.JWK{ + JSONWebKey: jose.JSONWebKey{ + Key: &priv.PublicKey, + KeyID: "key2", + Algorithm: "ES256", + }, + Kty: "EC", + Crv: "P-256", + }, + }, + }, nil +} + +func (o *Operation) onboardUser(sub string) (*user.Profile, error) { + userProfile := &user.Profile{ + ID: sub, + Data: make(map[string]string), + } + + err := user.NewStore(o.bootstrapStore).Save(userProfile) + if err != nil { + return nil, fmt.Errorf("failed to save user profile : %w", err) + } + + return userProfile, nil +} + +func (o *Operation) subject(w http.ResponseWriter, r *http.Request) (string, bool) { + authHeader := strings.TrimSpace(r.Header.Get("authorization")) + if authHeader == "" { + o.writeErrorResponse(w, http.StatusForbidden, "no credentials") + + return "", false + } + + switch { + case strings.HasPrefix(authHeader, gnapScheme): + return o.gnapSub(w, r, authHeader) + default: + o.writeErrorResponse(w, http.StatusBadRequest, "invalid authorization scheme") + + return "", false + } +} + +func (o *Operation) gnapSub(w http.ResponseWriter, _ *http.Request, authHeader string) (string, bool) { + token := authHeader[len(gnapScheme):] + + introspection, err := o.introspectHandler(&gnap.IntrospectRequest{ + AccessToken: token, + ResourceServer: o.gnapRSClient, + }) + if err != nil { + o.writeErrorResponse(w, http.StatusUnauthorized, "failed to introspect token: %s", err.Error()) + + return "", false + } + + if sub, ok := introspection.SubjectData["sub"]; ok { + return sub, true + } + + o.writeErrorResponse(w, http.StatusUnauthorized, "token does not grant access to subject id") + + return "", false +} + +func merge(existing *user.Profile, update *UpdateBootstrapDataRequest) *user.Profile { + merged := &user.Profile{ + ID: existing.ID, + AAGUID: existing.AAGUID, + Data: existing.Data, + } + + if merged.Data == nil { + merged.Data = make(map[string]string) + } + + for k, v := range update.Data { + if _, found := merged.Data[k]; !found { + merged.Data[k] = v + } + } + + return merged +} diff --git a/pkg/restapi/gnap/operations_test.go b/pkg/restapi/gnap/operations_test.go index fcfa4c7..794f23c 100644 --- a/pkg/restapi/gnap/operations_test.go +++ b/pkg/restapi/gnap/operations_test.go @@ -27,10 +27,12 @@ import ( "github.com/hyperledger/aries-framework-go/component/storageutil/mem" "github.com/hyperledger/aries-framework-go/pkg/doc/jose/jwk" mockstore "github.com/hyperledger/aries-framework-go/pkg/mock/storage" + "github.com/hyperledger/aries-framework-go/spi/storage" "github.com/square/go-jose/v3" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + "github.com/trustbloc/auth/pkg/bootstrap/user" "github.com/trustbloc/auth/pkg/gnap/accesspolicy" "github.com/trustbloc/auth/pkg/gnap/api" "github.com/trustbloc/auth/pkg/gnap/interact/redirect" @@ -76,7 +78,7 @@ func TestOperation_GetRESTHandlers(t *testing.T) { o := &Operation{} h := o.GetRESTHandlers() - require.Len(t, h, 7) + require.Len(t, h, 9) } func TestOperation_AuthProvidersHandler(t *testing.T) { @@ -268,7 +270,7 @@ func TestOperation_authContinueHandler(t *testing.T) { }) } -func TestOperation_introspectHandler(t *testing.T) { +func TestOperation_authIntrospectHandler(t *testing.T) { t.Run("fail to read request body", func(t *testing.T) { o := &Operation{} @@ -278,7 +280,7 @@ func TestOperation_introspectHandler(t *testing.T) { req := httptest.NewRequest(http.MethodPost, AuthRequestPath, &errorReader{err: expectErr}) - o.introspectHandler(rw, req) + o.authIntrospectHandler(rw, req) require.Equal(t, http.StatusInternalServerError, rw.Code) }) @@ -290,7 +292,7 @@ func TestOperation_introspectHandler(t *testing.T) { req := httptest.NewRequest(http.MethodPost, AuthRequestPath, nil) - o.introspectHandler(rw, req) + o.authIntrospectHandler(rw, req) require.Equal(t, http.StatusBadRequest, rw.Code) }) @@ -302,7 +304,7 @@ func TestOperation_introspectHandler(t *testing.T) { req := httptest.NewRequest(http.MethodPost, AuthRequestPath, bytes.NewReader([]byte("{}"))) - o.introspectHandler(rw, req) + o.authIntrospectHandler(rw, req) require.Equal(t, http.StatusUnauthorized, rw.Code) }) @@ -331,7 +333,7 @@ func TestOperation_introspectHandler(t *testing.T) { req, err = httpsig.Sign(req, intReqBytes, priv, "sha-256") require.NoError(t, err) - o.introspectHandler(rw, req) + o.authIntrospectHandler(rw, req) require.Equal(t, http.StatusOK, rw.Code) @@ -726,6 +728,64 @@ func TestOIDCCallbackHandler(t *testing.T) { require.Equal(t, http.StatusInternalServerError, result.Code) }) + t.Run("generic bootstrap store PUT error while onboarding user", func(t *testing.T) { + provider := uuid.New().String() + id := uuid.New().String() + state := uuid.New().String() + code := uuid.New().String() + config := config(t) + + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + id: {}, + }, + ErrGet: storage.ErrDataNotFound, + ErrPut: errors.New("generic"), + }, + } + + o, err := New(config) + require.NoError(t, err) + + o.cachedOIDCProviders = map[string]oidcProvider{ + provider: &mockOIDCProvider{ + name: provider, + oauth2Config: &mockOAuth2Config{ + exchangeVal: &mockToken{ + oauth2Claim: uuid.New().String(), + }, + }, + verifyVal: &mockToken{ + oidcClaimsFunc: func(v interface{}) error { + c, ok := v.(*oidcClaims) + require.True(t, ok) + c.Sub = uuid.New().String() + + return nil + }, + }, + }, + } + + data := &oidcTransientData{ + Provider: provider, + TxnID: "foo", + } + + dataBytes, err := json.Marshal(data) + require.NoError(t, err) + + err = o.transientStore.Put(state, dataBytes) + require.NoError(t, err) + + result := httptest.NewRecorder() + o.oidcCallbackHandler(result, newOIDCCallback(state, code)) + require.Equal(t, http.StatusInternalServerError, result.Code) + + require.Contains(t, result.Body.String(), "failed to onboard new user") + }) + t.Run("fail to complete interaction", func(t *testing.T) { provider := uuid.New().String() state := uuid.New().String() @@ -849,6 +909,322 @@ func TestOIDCCallbackHandler(t *testing.T) { require.Equal(t, http.StatusBadRequest, result.Code) require.Contains(t, result.Body.String(), "client provided invalid redirect URI") }) + + t.Run("generic bootstrap store PUT error while onboarding user", func(t *testing.T) { + provider := uuid.New().String() + id := uuid.New().String() + state := uuid.New().String() + config := config(t) + + config.TransientStoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + state: {Value: []byte(state)}, + }, + }, + } + + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + id: {}, + }, + ErrGet: storage.ErrDataNotFound, + ErrPut: errors.New("generic"), + }, + } + + svc, err := New(config) + require.NoError(t, err) + + svc.cachedOIDCProviders = map[string]oidcProvider{ + provider: &mockOIDCProvider{ + name: provider, + oauth2Config: &mockOAuth2Config{exchangeVal: &mockToken{ + oauth2Claim: uuid.New().String(), + }}, + verifyVal: &mockToken{ + oidcClaimsFunc: func(v interface{}) error { + c, ok := v.(*oidcClaims) + require.True(t, ok) + c.Sub = id + + return nil + }, + }, + }, + } + + result := httptest.NewRecorder() + svc.oidcCallbackHandler(result, newOIDCCallback(state, "code")) + require.Equal(t, http.StatusInternalServerError, result.Code) + }) +} + +func TestGetBootstrapDataHandler(t *testing.T) { + t.Run("returns bootstrap data when using GNAP token", func(t *testing.T) { + userSub := uuid.New().String() + config := config(t) + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": userSub}, + }, nil + }) + expected := &user.Profile{ + ID: uuid.New().String(), + AAGUID: uuid.New().String(), + Data: map[string]string{ + "primary vault": uuid.New().String(), + "backup vault": uuid.New().String(), + }, + } + + err = svc.bootstrapStore.Put(userSub, marshal(t, expected)) + require.NoError(t, err) + + w := httptest.NewRecorder() + + request := newGetBootstrapDataRequest() + request.Header.Set("authorization", "GNAP 123") + + svc.getBootstrapDataHandler(w, request) + require.Equal(t, http.StatusOK, w.Code) + result := &BootstrapData{} + err = json.NewDecoder(w.Body).Decode(result) + require.NoError(t, err) + require.Equal(t, config.BootstrapConfig.DocumentSDSVaultURL, result.DocumentSDSVaultURL) + require.Equal(t, config.BootstrapConfig.KeySDSVaultURL, result.KeySDSVaultURL) + require.Equal(t, config.BootstrapConfig.OpsKeyServerURL, result.OpsKeyServerURL) + require.Equal(t, expected.Data, result.Data) + }) + + t.Run("forbidden if auth header is missing", func(t *testing.T) { + svc, err := New(config(t)) + require.NoError(t, err) + w := httptest.NewRecorder() + svc.getBootstrapDataHandler(w, httptest.NewRequest(http.MethodGet, "http://examepl.com/bootstrap", nil)) + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "no credentials") + }) + + t.Run("bad request if auth scheme is invalid", func(t *testing.T) { + request := newGetBootstrapDataRequest() + request.Header.Set("authorization", "invalid 123") + svc, err := New(config(t)) + require.NoError(t, err) + w := httptest.NewRecorder() + svc.getBootstrapDataHandler(w, request) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "invalid authorization scheme") + }) + + t.Run("unauthorized if invalid gnap token", func(t *testing.T) { + request := newGetBootstrapDataRequest() + request.Header.Set("authorization", "GNAP 123") + svc, err := New(config(t)) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return nil, fmt.Errorf("gnap introspect error") + }) + require.NoError(t, err) + w := httptest.NewRecorder() + svc.getBootstrapDataHandler(w, request) + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Body.String(), "gnap introspect error") + }) + + t.Run("unauthorized if gnap token does not grant access to subject id", func(t *testing.T) { + request := newGetBootstrapDataRequest() + request.Header.Set("authorization", "GNAP 123") + svc, err := New(config(t)) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{}, + }, nil + }) + require.NoError(t, err) + w := httptest.NewRecorder() + svc.getBootstrapDataHandler(w, request) + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Body.String(), "does not grant access") + }) + + t.Run("bad request if user does not have bootstrap data", func(t *testing.T) { + userSub := uuid.New().String() + config := config(t) + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": userSub}, + }, nil + }) + w := httptest.NewRecorder() + svc.getBootstrapDataHandler(w, newGetBootstrapDataRequest()) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "invalid handle") + }) + + t.Run("internal server error if bootstrap store FETCH fails generically", func(t *testing.T) { + userSub := uuid.New().String() + config := config(t) + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + userSub: {Value: marshal(t, &user.Profile{})}, + }, + ErrGet: errors.New("generic"), + }, + } + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": userSub}, + }, nil + }) + w := httptest.NewRecorder() + svc.getBootstrapDataHandler(w, newGetBootstrapDataRequest()) + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "failed to query bootstrap store for handle") + }) +} + +func TestPostBootstrapDataHandler(t *testing.T) { + t.Run("updates bootstrap data when using GNAP token", func(t *testing.T) { + expected := &user.Profile{ + ID: uuid.New().String(), + AAGUID: uuid.New().String(), + Data: map[string]string{ + "docsSDS": "https://example.org/edvs/123", + "keysSDS": "https://example.org/edvs/456", + "opskeys": "https://example.org/kms/456", + }, + } + config := config(t) + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + expected.ID: {Value: marshal(t, &user.Profile{ + ID: expected.ID, + AAGUID: expected.AAGUID, + })}, + }, + }, + } + svc, err := New(config) + require.NoError(t, err) + + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": expected.ID}, + }, nil + }) + + result := httptest.NewRecorder() + + request := newPostBootstrapDataRequest(t, &UpdateBootstrapDataRequest{ + Data: expected.Data, + }) + + svc.postBootstrapDataHandler(result, request) + require.Equal(t, http.StatusOK, result.Code) + raw, err := svc.bootstrapStore.Get(expected.ID) + require.NoError(t, err) + update := &user.Profile{} + err = json.NewDecoder(bytes.NewReader(raw)).Decode(update) + require.NoError(t, err) + require.Equal(t, expected, update) + }) + + t.Run("error badrequest if payload is not json", func(t *testing.T) { + userSub := uuid.New().String() + config := config(t) + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + userSub: {Value: nil}, + }, + }, + } + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": userSub}, + }, nil + }) + request := httptest.NewRequest(http.MethodPost, "https://example.org/bootstrap", bytes.NewReader([]byte("}"))) + request.Header.Set("authorization", "GNAP 123") + result := httptest.NewRecorder() + svc.postBootstrapDataHandler(result, request) + require.Equal(t, http.StatusBadRequest, result.Code) + require.Contains(t, result.Body.String(), "failed to decode request") + }) + + t.Run("error conflict if user does not exist", func(t *testing.T) { + config := config(t) + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": uuid.New().String()}, + }, nil + }) + result := httptest.NewRecorder() + svc.postBootstrapDataHandler(result, newPostBootstrapDataRequest(t, &UpdateBootstrapDataRequest{})) + require.Equal(t, http.StatusConflict, result.Code) + require.Contains(t, result.Body.String(), "associated bootstrap data not found") + }) + + t.Run("internal server error on generic FETCH bootstrap store error", func(t *testing.T) { + userSub := uuid.New().String() + config := config(t) + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + userSub: {Value: nil}, + }, + ErrGet: errors.New("generic"), + }, + } + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": userSub}, + }, nil + }) + result := httptest.NewRecorder() + svc.postBootstrapDataHandler(result, newPostBootstrapDataRequest(t, &UpdateBootstrapDataRequest{})) + require.Equal(t, http.StatusInternalServerError, result.Code) + require.Contains(t, result.Body.String(), "failed to query storage") + }) + + t.Run("internal server error if cannot persist update to bootstrap store", func(t *testing.T) { + userSub := uuid.New().String() + config := config(t) + config.StoreProvider = &mockstore.MockStoreProvider{ + Store: &mockstore.MockStore{ + Store: map[string]mockstore.DBEntry{ + userSub: {Value: marshal(t, &user.Profile{})}, + }, + ErrPut: errors.New("generic"), + }, + } + svc, err := New(config) + require.NoError(t, err) + svc.SetIntrospectHandler(func(req *gnap.IntrospectRequest) (*gnap.IntrospectResponse, error) { + return &gnap.IntrospectResponse{ + SubjectData: map[string]string{"sub": userSub}, + }, nil + }) + result := httptest.NewRecorder() + svc.postBootstrapDataHandler(result, newPostBootstrapDataRequest(t, &UpdateBootstrapDataRequest{})) + require.Equal(t, http.StatusInternalServerError, result.Code) + require.Contains(t, result.Body.String(), "failed to update storage") + }) } func Test_Full_Flow(t *testing.T) { @@ -1034,7 +1410,7 @@ func Test_Full_Flow(t *testing.T) { req, err = httpsig.Sign(req, intReqBytes, rsPriv, "sha-256") require.NoError(t, err) - o.introspectHandler(rw, req) + o.authIntrospectHandler(rw, req) require.Equal(t, http.StatusOK, rw.Code) @@ -1114,6 +1490,25 @@ func newOIDCCallback(state, code string) *http.Request { fmt.Sprintf("http://example.com/oauth2/callback?state=%s&code=%s", state, code), nil) } +func newGetBootstrapDataRequest() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://example.com/bootstrap", nil) + r.Header.Set("Authorization", "GNAP 123") + + return r +} + +func newPostBootstrapDataRequest(t *testing.T, params *UpdateBootstrapDataRequest) *http.Request { + t.Helper() + + bits, err := json.Marshal(params) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "http://example.com/bootstrap", bytes.NewReader(bits)) + r.Header.Set("Authorization", "GNAP 123") + + return r +} + type mockToken struct { oauth2Claim interface{} oidcClaimsFunc func(v interface{}) error @@ -1172,11 +1567,25 @@ func config(t *testing.T) *Config { }, }, }, + BootstrapConfig: &BootstrapConfig{ + DocumentSDSVaultURL: "http://docs.sds.example.org/sds/vaults", + KeySDSVaultURL: "http://keys.sds.example.org/sds/vaults/", + OpsKeyServerURL: "http://ops.kms.example.org/kms/keystores/", + }, TransientStoreProvider: mem.NewProvider(), StartupTimeout: 1, } } +func marshal(t *testing.T, v interface{}) []byte { + t.Helper() + + bits, err := json.Marshal(v) + require.NoError(t, err) + + return bits +} + type errorReader struct { err error } diff --git a/pkg/restapi/operation/operations.go b/pkg/restapi/operation/operations.go index 5f83d75..d1cd6be 100644 --- a/pkg/restapi/operation/operations.go +++ b/pkg/restapi/operation/operations.go @@ -622,12 +622,9 @@ func (o *Operation) postBootstrapDataHandler(w http.ResponseWriter, r *http.Requ existing, err := user.NewStore(o.bootstrapStore).Get(subject) if errors.Is(err, storage.ErrDataNotFound) { - existing, err = o.onboardUser(subject) // TODO: Onboard user as part of GNAP flow when we get access token? - if err != nil { - o.writeErrorResponse(w, http.StatusInternalServerError, fmt.Sprintf("failed to onboard user: %s", err)) + o.writeErrorResponse(w, http.StatusConflict, "associated bootstrap data not found") - return - } + return } if err != nil { diff --git a/pkg/restapi/operation/operations_test.go b/pkg/restapi/operation/operations_test.go index 935fa57..095891b 100644 --- a/pkg/restapi/operation/operations_test.go +++ b/pkg/restapi/operation/operations_test.go @@ -1033,29 +1033,19 @@ func TestPostBootstrapDataHandler(t *testing.T) { require.Contains(t, result.Body.String(), "failed to decode request") }) - t.Run("error when onboarding user for gnap flow", func(t *testing.T) { - id := uuid.New().String() + t.Run("error conflict if user does not exist", func(t *testing.T) { config := config(t) config.Hydra = &mockHydra{ introspectValue: &admin.IntrospectOAuth2TokenOK{Payload: &models.OAuth2TokenIntrospection{ Sub: uuid.New().String(), }}, } - config.StoreProvider = &mockstore.MockStoreProvider{ - Store: &mockstore.MockStore{ - Store: map[string]mockstore.DBEntry{ - id: {}, - }, - ErrGet: storage.ErrDataNotFound, - ErrPut: errors.New("generic"), - }, - } svc, err := New(config) require.NoError(t, err) result := httptest.NewRecorder() svc.postBootstrapDataHandler(result, newPostBootstrapDataRequest(t, &UpdateBootstrapDataRequest{})) - require.Equal(t, http.StatusInternalServerError, result.Code) - require.Contains(t, result.Body.String(), "failed to onboard user") + require.Equal(t, http.StatusConflict, result.Code) + require.Contains(t, result.Body.String(), "associated bootstrap data not found") }) t.Run("internal server error on generic FETCH bootstrap store error", func(t *testing.T) {