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