469 lines
14 KiB
Go
469 lines
14 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
|
tenantpb "github.com/cs3org/go-cs3apis/cs3/identity/tenant/v1beta1"
|
|
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
|
rpcpb "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
|
"github.com/qsfera/server/pkg/log"
|
|
"github.com/qsfera/server/pkg/oidc"
|
|
"github.com/qsfera/server/services/proxy/pkg/config"
|
|
"github.com/qsfera/server/services/proxy/pkg/router"
|
|
"github.com/qsfera/server/services/proxy/pkg/user/backend"
|
|
"github.com/qsfera/server/services/proxy/pkg/user/backend/mocks"
|
|
userRoleMocks "github.com/qsfera/server/services/proxy/pkg/userroles/mocks"
|
|
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
|
|
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
|
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
|
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
|
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
const (
|
|
testIdP = "https://idx.example.com"
|
|
testTenantA = "tenant-a"
|
|
testTenantB = "tenant-b"
|
|
testJWTSecret = "change-me"
|
|
testSvcAccountID = "svc-account-id"
|
|
testSvcAccountSecret = "svc-account-secret"
|
|
testSvcAccountToken = "svc-account-token"
|
|
)
|
|
|
|
func TestTokenIsAddedWithMailClaim(t *testing.T) {
|
|
sut := newMockAccountResolver(&userv1beta1.User{
|
|
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
|
Mail: "foo@example.com",
|
|
}, nil, oidc.Email, "mail", false)
|
|
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.Email: "foo@example.com",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.NotEmpty(t, token)
|
|
assert.Contains(t, token, "eyJ")
|
|
}
|
|
|
|
func TestTokenIsAddedWithUsernameClaim(t *testing.T) {
|
|
sut := newMockAccountResolver(&userv1beta1.User{
|
|
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
|
Mail: "foo@example.com",
|
|
}, nil, oidc.PreferredUsername, "username", false)
|
|
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.NotEmpty(t, token)
|
|
|
|
assert.Contains(t, token, "eyJ")
|
|
}
|
|
|
|
func TestTokenIsAddedWithDotUsernamePathClaim(t *testing.T) {
|
|
sut := newMockAccountResolver(&userv1beta1.User{
|
|
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
|
Mail: "foo@example.com",
|
|
}, nil, "li.un", "username", false)
|
|
|
|
// This is how lico adds the username to the access token
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
"li": map[string]any{
|
|
"un": "foo",
|
|
},
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.NotEmpty(t, token)
|
|
|
|
assert.Contains(t, token, "eyJ")
|
|
}
|
|
|
|
func TestTokenIsAddedWithDottedUsernameClaim(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
oidcClaim string
|
|
// comment describing what the claim exercises
|
|
desc string
|
|
}{
|
|
{
|
|
name: "escaped dot treated as literal key",
|
|
oidcClaim: "li\\.un",
|
|
desc: "li\\.un escapes the dot so the claim is looked up as the literal key \"li.un\"",
|
|
},
|
|
{
|
|
name: "dotted path falls back to literal key",
|
|
oidcClaim: "li.un",
|
|
desc: "li.un is first tried as a nested path; when \"un\" is absent under \"li\", it falls back to the literal key \"li.un\"",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sut := newMockAccountResolver(&userv1beta1.User{
|
|
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
|
Mail: "foo@example.com",
|
|
}, nil, tc.oidcClaim, "username", false)
|
|
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
"li.un": "foo",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.NotEmpty(t, token)
|
|
assert.Contains(t, token, "eyJ")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNSkipOnNoClaims(t *testing.T) {
|
|
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.Email, "mail", false)
|
|
req, rw := mockRequest(nil)
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get("x-access-token")
|
|
assert.Empty(t, token)
|
|
assert.Equal(t, http.StatusOK, rw.Code)
|
|
}
|
|
|
|
func TestUnauthorizedOnUserNotFound(t *testing.T) {
|
|
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.PreferredUsername, "username", false)
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.Empty(t, token)
|
|
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
|
}
|
|
|
|
func TestUnauthorizedOnUserDisabled(t *testing.T) {
|
|
sut := newMockAccountResolver(nil, backend.ErrAccountDisabled, oidc.PreferredUsername, "username", false)
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.Empty(t, token)
|
|
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
|
}
|
|
|
|
func TestInternalServerErrorOnMissingMailAndUsername(t *testing.T) {
|
|
sut := newMockAccountResolver(nil, backend.ErrAccountNotFound, oidc.Email, "mail", false)
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.Empty(t, token)
|
|
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
|
}
|
|
|
|
func TestUnauthorizedOnMissingTenantId(t *testing.T) {
|
|
sut := newMockAccountResolver(
|
|
&userv1beta1.User{
|
|
Id: &userv1beta1.UserId{Idp: testIdP, OpaqueId: "123"},
|
|
Username: "foo",
|
|
},
|
|
nil, oidc.PreferredUsername, "username", true)
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.Empty(t, token)
|
|
assert.Equal(t, http.StatusUnauthorized, rw.Code)
|
|
}
|
|
|
|
func TestTokenIsAddedWhenUserHasTenantId(t *testing.T) {
|
|
sut := newMockAccountResolver(
|
|
&userv1beta1.User{
|
|
Id: &userv1beta1.UserId{
|
|
Idp: testIdP,
|
|
OpaqueId: "123",
|
|
TenantId: "tenant1",
|
|
},
|
|
Username: "foo",
|
|
},
|
|
nil, oidc.PreferredUsername, "username", true)
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
token := req.Header.Get(revactx.TokenHeader)
|
|
assert.NotEmpty(t, token)
|
|
assert.Contains(t, token, "eyJ")
|
|
}
|
|
|
|
func TestTenantClaimValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestTenant string
|
|
wantToken bool
|
|
wantStatusCode int
|
|
}{
|
|
{
|
|
name: "token added when tenant claim matches",
|
|
requestTenant: testTenantA,
|
|
wantToken: true,
|
|
wantStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "unauthorized when tenant claim does not match",
|
|
requestTenant: testTenantB,
|
|
wantToken: false,
|
|
wantStatusCode: http.StatusUnauthorized,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
user := &userv1beta1.User{
|
|
Id: &userv1beta1.UserId{
|
|
Idp: testIdP,
|
|
OpaqueId: "123",
|
|
TenantId: testTenantA,
|
|
},
|
|
Username: "foo",
|
|
}
|
|
|
|
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
|
|
s, _ := scope.AddOwnerScope(nil)
|
|
token, _ := tokenManager.MintToken(context.Background(), user, s)
|
|
|
|
ub := mocks.UserBackend{}
|
|
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
|
|
ra := userRoleMocks.UserRoleAssigner{}
|
|
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
|
|
|
|
sut := AccountResolver(
|
|
Logger(log.NewLogger()),
|
|
UserProvider(&ub),
|
|
UserRoleAssigner(&ra),
|
|
UserOIDCClaim(oidc.PreferredUsername),
|
|
UserCS3Claim("username"),
|
|
TenantOIDCClaim("tenant_id"),
|
|
MultiTenantEnabled(true),
|
|
)(mockHandler{})
|
|
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
"tenant_id": tc.requestTenant,
|
|
})
|
|
|
|
sut.ServeHTTP(rw, req)
|
|
|
|
if tc.wantToken {
|
|
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
|
|
} else {
|
|
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
|
|
}
|
|
assert.Equal(t, tc.wantStatusCode, rw.Code)
|
|
})
|
|
}
|
|
}
|
|
|
|
func newMockAccountResolver(userBackendResult *userv1beta1.User, userBackendErr error, oidcclaim, cs3claim string, multiTenant bool) http.Handler {
|
|
tokenManager, _ := jwt.New(map[string]any{
|
|
"secret": testJWTSecret,
|
|
"expires": int64(60),
|
|
})
|
|
|
|
token := ""
|
|
if userBackendResult != nil {
|
|
s, _ := scope.AddOwnerScope(nil)
|
|
token, _ = tokenManager.MintToken(context.Background(), userBackendResult, s)
|
|
}
|
|
|
|
ub := mocks.UserBackend{}
|
|
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, token, userBackendErr)
|
|
ub.On("GetUserRoles", mock.Anything, mock.Anything).Return(userBackendResult, nil)
|
|
|
|
ra := userRoleMocks.UserRoleAssigner{}
|
|
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(userBackendResult, nil)
|
|
|
|
return AccountResolver(
|
|
Logger(log.NewLogger()),
|
|
UserProvider(&ub),
|
|
UserRoleAssigner(&ra),
|
|
SkipUserInfo(false),
|
|
UserOIDCClaim(oidcclaim),
|
|
UserCS3Claim(cs3claim),
|
|
AutoprovisionAccounts(false),
|
|
MultiTenantEnabled(multiTenant),
|
|
)(mockHandler{})
|
|
}
|
|
|
|
func mockRequest(claims map[string]any) (*http.Request, *httptest.ResponseRecorder) {
|
|
if claims == nil {
|
|
return httptest.NewRequest("GET", "http://example.com/foo", nil), httptest.NewRecorder()
|
|
}
|
|
|
|
ctx := oidc.NewContext(context.Background(), claims)
|
|
ctx = router.SetRoutingInfo(ctx, router.RoutingInfo{})
|
|
req := httptest.NewRequest("GET", "http://example.com/foo", nil).WithContext(ctx)
|
|
rw := httptest.NewRecorder()
|
|
|
|
return req, rw
|
|
}
|
|
|
|
type mockHandler struct{}
|
|
|
|
func (m mockHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {}
|
|
|
|
func TestTenantIDMapping(t *testing.T) {
|
|
const (
|
|
externalTenantID = "external-tenant-x"
|
|
internalTenantID = testTenantA
|
|
)
|
|
|
|
user := &userv1beta1.User{
|
|
Id: &userv1beta1.UserId{
|
|
Idp: testIdP,
|
|
OpaqueId: "123",
|
|
TenantId: internalTenantID,
|
|
},
|
|
Username: "foo",
|
|
}
|
|
|
|
tokenManager, _ := jwt.New(map[string]any{"secret": testJWTSecret, "expires": int64(60)})
|
|
s, _ := scope.AddOwnerScope(nil)
|
|
token, _ := tokenManager.MintToken(context.Background(), user, s)
|
|
|
|
newSUT := func(t *testing.T, gatewayClient gateway.GatewayAPIClient) http.Handler {
|
|
t.Helper()
|
|
gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
|
|
"GatewaySelector",
|
|
"qsfera.api.gateway",
|
|
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
|
return gatewayClient
|
|
},
|
|
)
|
|
t.Cleanup(func() { pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway") })
|
|
|
|
ub := mocks.UserBackend{}
|
|
ub.On("GetUserByClaims", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(user, token, nil)
|
|
ra := userRoleMocks.UserRoleAssigner{}
|
|
ra.On("UpdateUserRoleAssignment", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
|
|
|
|
return AccountResolver(
|
|
Logger(log.NewLogger()),
|
|
UserProvider(&ub),
|
|
UserRoleAssigner(&ra),
|
|
UserOIDCClaim(oidc.PreferredUsername),
|
|
UserCS3Claim("username"),
|
|
TenantOIDCClaim("tenant_id"),
|
|
MultiTenantEnabled(true),
|
|
TenantIDMappingEnabled(true),
|
|
ServiceAccount(config.ServiceAccount{
|
|
ServiceAccountID: testSvcAccountID,
|
|
ServiceAccountSecret: testSvcAccountSecret,
|
|
}),
|
|
WithRevaGatewaySelector(gatewaySelector),
|
|
)(mockHandler{})
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
tenantResponse *tenantpb.GetTenantByClaimResponse
|
|
wantToken bool
|
|
wantStatusCode int
|
|
}{
|
|
{
|
|
name: "token added when external tenant maps to user internal tenant",
|
|
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
|
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
|
Tenant: &tenantpb.Tenant{Id: internalTenantID, ExternalId: externalTenantID},
|
|
},
|
|
wantToken: true,
|
|
wantStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "unauthorized when external tenant maps to a different internal tenant",
|
|
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
|
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
|
Tenant: &tenantpb.Tenant{Id: testTenantB, ExternalId: externalTenantID},
|
|
},
|
|
wantToken: false,
|
|
wantStatusCode: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "unauthorized when external tenant is not found",
|
|
tenantResponse: &tenantpb.GetTenantByClaimResponse{
|
|
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_NOT_FOUND, Message: "not found"},
|
|
},
|
|
wantToken: false,
|
|
wantStatusCode: http.StatusUnauthorized,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gwc := &cs3mocks.GatewayAPIClient{}
|
|
gwc.On("Authenticate", mock.Anything, &gateway.AuthenticateRequest{
|
|
Type: "serviceaccounts",
|
|
ClientId: testSvcAccountID,
|
|
ClientSecret: testSvcAccountSecret,
|
|
}).Return(&gateway.AuthenticateResponse{
|
|
Status: &rpcpb.Status{Code: rpcpb.Code_CODE_OK},
|
|
Token: testSvcAccountToken,
|
|
}, nil)
|
|
gwc.On("GetTenantByClaim", mock.Anything, &tenantpb.GetTenantByClaimRequest{
|
|
Claim: "externalid",
|
|
Value: externalTenantID,
|
|
}).Return(tc.tenantResponse, nil)
|
|
|
|
req, rw := mockRequest(map[string]any{
|
|
oidc.Iss: testIdP,
|
|
oidc.PreferredUsername: "foo",
|
|
"tenant_id": externalTenantID,
|
|
})
|
|
newSUT(t, gwc).ServeHTTP(rw, req)
|
|
|
|
if tc.wantToken {
|
|
assert.NotEmpty(t, req.Header.Get(revactx.TokenHeader))
|
|
} else {
|
|
assert.Empty(t, req.Header.Get(revactx.TokenHeader))
|
|
}
|
|
assert.Equal(t, tc.wantStatusCode, rw.Code)
|
|
gwc.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|