diff --git a/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx b/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx
index 360d62d5b4..9b78eae182 100644
--- a/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx
+++ b/packages/twenty-front/src/modules/settings/profile/devices/components/SettingsProfileDevicesSection.tsx
@@ -132,10 +132,9 @@ export const SettingsProfileDevicesSection = () => {
{hasOtherSessions && (
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts
index d43aeea61a..669daf16d0 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts
@@ -147,9 +147,12 @@ describe('ApplicationAuthorizationService', () => {
});
});
- describe('findActiveAuthorizationsForUser', () => {
+ describe('findActiveAuthorizationsForUserWorkspace', () => {
it('should only return unrevoked authorizations whose application still exists', async () => {
- await service.findActiveAuthorizationsForUser(userId);
+ await service.findActiveAuthorizationsForUserWorkspace({
+ userId,
+ workspaceId,
+ });
expect(queryBuilder.innerJoinAndSelect).toHaveBeenCalledWith(
'applicationAuthorization.application',
@@ -164,8 +167,23 @@ describe('ApplicationAuthorizationService', () => {
);
});
+ it('should scope the query to the current workspace', async () => {
+ await service.findActiveAuthorizationsForUserWorkspace({
+ userId,
+ workspaceId,
+ });
+
+ expect(queryBuilder.andWhere).toHaveBeenCalledWith(
+ 'applicationAuthorization.workspaceId = :workspaceId',
+ { workspaceId },
+ );
+ });
+
it('should put the most recently used authorization first', async () => {
- await service.findActiveAuthorizationsForUser(userId);
+ await service.findActiveAuthorizationsForUserWorkspace({
+ userId,
+ workspaceId,
+ });
expect(queryBuilder.orderBy).toHaveBeenCalledWith(
'applicationAuthorization.lastUsedAt',
@@ -174,17 +192,23 @@ describe('ApplicationAuthorizationService', () => {
});
});
- describe('revokeAuthorizationById', () => {
- it('should scope the update by userId so an id from another user matches nothing', async () => {
+ describe('revokeAuthorizationByIdForUserWorkspace', () => {
+ it('should scope the update by userId and workspaceId so an id from elsewhere matches nothing', async () => {
repository.update.mockResolvedValue(buildUpdateResult(0));
- const revoked = await service.revokeAuthorizationById({
+ const revoked = await service.revokeAuthorizationByIdForUserWorkspace({
authorizationId,
userId: otherUserId,
+ workspaceId,
});
expect(repository.update).toHaveBeenCalledWith(
- { id: authorizationId, userId: otherUserId, revokedAt: IsNull() },
+ {
+ id: authorizationId,
+ userId: otherUserId,
+ workspaceId,
+ revokedAt: IsNull(),
+ },
expect.objectContaining({ revokedAt: expect.any(Date) }),
);
expect(revoked).toBe(false);
@@ -194,7 +218,11 @@ describe('ApplicationAuthorizationService', () => {
repository.update.mockResolvedValue(buildUpdateResult(1));
expect(
- await service.revokeAuthorizationById({ authorizationId, userId }),
+ await service.revokeAuthorizationByIdForUserWorkspace({
+ authorizationId,
+ userId,
+ workspaceId,
+ }),
).toBe(true);
});
@@ -202,7 +230,11 @@ describe('ApplicationAuthorizationService', () => {
repository.update.mockResolvedValue(buildUpdateResult(0));
expect(
- await service.revokeAuthorizationById({ authorizationId, userId }),
+ await service.revokeAuthorizationByIdForUserWorkspace({
+ authorizationId,
+ userId,
+ workspaceId,
+ }),
).toBe(false);
});
});
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts
index e971ffb3ff..cb74d88057 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts
@@ -7,16 +7,21 @@ import { type ApplicationAuthorizationEntity } from 'src/engine/core-modules/app
import { ApplicationAuthorizationDTO } from 'src/engine/core-modules/application/application-authorization/dtos/application-authorization.dto';
import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
+import { isDefined } from 'twenty-shared/utils';
+
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
+import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
-// User-scoped on purpose: these are the applications this person authorized,
-// which they may revoke for themselves without affecting anyone else. Removing
-// an integration for the whole workspace is uninstalling it, an admin action
-// that lives elsewhere.
+// Scoped to this person within this workspace: an authorization grants an
+// application access to one workspace's data, so it is listed and revoked from
+// that workspace only, and revoking affects nobody else. Removing an
+// integration for the whole workspace is uninstalling it, an admin action that
+// lives elsewhere.
@UsePipes(ResolverValidationPipe)
@UseFilters(AuthGraphqlApiExceptionFilter)
@MetadataResolver()
@@ -29,10 +34,19 @@ export class ApplicationAuthorizationResolver {
@UseGuards(UserAuthGuard, NoPermissionGuard)
async currentUserApplicationAuthorizations(
@AuthUser() user: AuthContextUser,
+ @AuthWorkspace({ allowUndefined: true }) workspace:
+ | WorkspaceEntity
+ | undefined,
): Promise {
+ // UserAuthGuard admits workspace-agnostic credentials, which have no
+ // workspace to scope to. Nothing is in scope rather than everything.
+ if (!isDefined(workspace)) {
+ return [];
+ }
+
const authorizations =
- await this.applicationAuthorizationService.findActiveAuthorizationsForUser(
- user.id,
+ await this.applicationAuthorizationService.findActiveAuthorizationsForUserWorkspace(
+ { userId: user.id, workspaceId: workspace.id },
);
return authorizations.map((authorization) =>
@@ -44,13 +58,23 @@ export class ApplicationAuthorizationResolver {
@UseGuards(UserAuthGuard, NoPermissionGuard)
async revokeApplicationAuthorization(
@AuthUser() user: AuthContextUser,
+ @AuthWorkspace({ allowUndefined: true }) workspace:
+ | WorkspaceEntity
+ | undefined,
@Args('applicationAuthorizationId', { type: () => UUIDScalarType })
applicationAuthorizationId: string,
): Promise {
- return await this.applicationAuthorizationService.revokeAuthorizationById({
- authorizationId: applicationAuthorizationId,
- userId: user.id,
- });
+ if (!isDefined(workspace)) {
+ return false;
+ }
+
+ return await this.applicationAuthorizationService.revokeAuthorizationByIdForUserWorkspace(
+ {
+ authorizationId: applicationAuthorizationId,
+ userId: user.id,
+ workspaceId: workspace.id,
+ },
+ );
}
private toApplicationAuthorizationDTO(
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts
index 0bd0c29300..93e04f183e 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts
@@ -99,13 +99,23 @@ export class ApplicationAuthorizationService {
// Inner join, so an application that has been soft-deleted takes its
// authorizations off the list rather than surfacing them with nothing to
// name them.
- async findActiveAuthorizationsForUser(
- userId: string,
- ): Promise {
+ // Scoped to one workspace: an authorization grants an application access to
+ // this workspace's data, so it is this workspace's to list and revoke. The
+ // same person's grants elsewhere are not visible from here.
+ async findActiveAuthorizationsForUserWorkspace({
+ userId,
+ workspaceId,
+ }: {
+ userId: string;
+ workspaceId: string;
+ }): Promise {
return await this.applicationAuthorizationRepository
.createQueryBuilder('applicationAuthorization')
.innerJoinAndSelect('applicationAuthorization.application', 'application')
.where('applicationAuthorization.userId = :userId', { userId })
+ .andWhere('applicationAuthorization.workspaceId = :workspaceId', {
+ workspaceId,
+ })
.andWhere('applicationAuthorization.revokedAt IS NULL')
.orderBy('applicationAuthorization.lastUsedAt', 'DESC')
.getMany();
@@ -118,18 +128,22 @@ export class ApplicationAuthorizationService {
);
}
- // Scoped by userId in the UPDATE itself rather than read-then-write, so one
- // user can never revoke another user's authorization by guessing an id.
- async revokeAuthorizationById({
+ // Scoped in the UPDATE itself rather than read-then-write, so one user can
+ // never revoke another user's authorization, or their own in another
+ // workspace, by guessing an id.
+ async revokeAuthorizationByIdForUserWorkspace({
authorizationId,
userId,
+ workspaceId,
}: {
authorizationId: string;
userId: string;
+ workspaceId: string;
}): Promise {
return await this.revokeMatching({
id: authorizationId,
userId,
+ workspaceId,
});
}
@@ -148,7 +162,7 @@ export class ApplicationAuthorizationService {
// rules out an empty criteria object, which would revoke every row.
private async revokeMatching(
criteria:
- | { id: string; userId: string }
+ | { id: string; userId: string; workspaceId: string }
| { userId: string; applicationId: string },
): Promise {
const { affected } = await this.applicationAuthorizationRepository.update(
diff --git a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts
index f66cb77da5..ff7c4bafd9 100644
--- a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.spec.ts
@@ -447,27 +447,34 @@ describe('UserSessionService', () => {
});
});
- describe('revokeSessionByIdForUser', () => {
- it('should scope the lookup to the requesting user', async () => {
+ describe('revokeSessionByIdForUserWorkspace', () => {
+ it('should scope the lookup to the requesting user and workspace', async () => {
const sessionId = randomUUID();
const userId = randomUUID();
+ const workspaceId = randomUUID();
const findOneBySpy = jest
.spyOn(userSessionRepository, 'findOneBy')
.mockResolvedValue(null);
await expect(
- service.revokeSessionByIdForUser({
+ service.revokeSessionByIdForUserWorkspace({
sessionId,
userId,
+ workspaceId,
reason: UserSessionRevokedReason.UserRevoked,
}),
).rejects.toThrow('Session not found');
- expect(findOneBySpy).toHaveBeenCalledWith({ id: sessionId, userId });
+ expect(findOneBySpy).toHaveBeenCalledWith({
+ id: sessionId,
+ userId,
+ workspaceId,
+ });
});
it('should revoke a session the user owns and drop its cache entry', async () => {
const userId = randomUUID();
+ const workspaceId = randomUUID();
const session = buildActiveSession({ userId, tokenHash: 'owned-hash' });
jest.spyOn(userSessionRepository, 'findOneBy').mockResolvedValue(session);
@@ -475,9 +482,10 @@ describe('UserSessionService', () => {
.spyOn(userSessionRepository, 'update')
.mockResolvedValue({ affected: 1 } as never);
- const wasRevoked = await service.revokeSessionByIdForUser({
+ const wasRevoked = await service.revokeSessionByIdForUserWorkspace({
sessionId: session.id,
userId,
+ workspaceId,
reason: UserSessionRevokedReason.UserRevoked,
});
@@ -571,6 +579,42 @@ describe('UserSessionService', () => {
);
});
+ it('should narrow to one workspace when given one, and stay account-wide otherwise', async () => {
+ const userId = randomUUID();
+ const workspaceId = randomUUID();
+
+ const scopedQueryBuilder = mockRevokingQueryBuilder([
+ buildActiveSession({ userId, tokenHash: 'hash' }),
+ ]);
+
+ await service.revokeAllSessionsForUser({
+ userId,
+ workspaceId,
+ reason: UserSessionRevokedReason.UserRevoked,
+ });
+
+ expect(scopedQueryBuilder.andWhere).toHaveBeenCalledWith(
+ expect.any(String),
+ { workspaceId },
+ );
+
+ const accountWideQueryBuilder = mockRevokingQueryBuilder([
+ buildActiveSession({ userId, tokenHash: 'hash' }),
+ ]);
+
+ // Password change must reach every workspace, so an absent workspaceId
+ // has to stay unscoped rather than defaulting to one.
+ await service.revokeAllSessionsForUser({
+ userId,
+ reason: UserSessionRevokedReason.PasswordChanged,
+ });
+
+ expect(accountWideQueryBuilder.andWhere).not.toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ workspaceId: expect.anything() }),
+ );
+ });
+
it('should report nothing revoked when no session matched', async () => {
mockRevokingQueryBuilder([]);
diff --git a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts
index fd5950048d..512fef5eef 100644
--- a/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/user-session/services/user-session.service.ts
@@ -418,15 +418,24 @@ export class UserSessionService {
});
}
- async findActiveSessionsForUser(
- userId: string,
- ): Promise {
+ // Scoped to one workspace: a session belongs to the workspace its exchange
+ // selected, and the same person's membership of another workspace is not that
+ // workspace's business. Sessions with no workspace are the workspace-agnostic
+ // ones minted on the default subdomain, which belong to no workspace's list.
+ async findActiveSessionsForUserWorkspace({
+ userId,
+ workspaceId,
+ }: {
+ userId: string;
+ workspaceId: string;
+ }): Promise {
const now = new Date();
const idleTimeoutMs = this.getIdleTimeoutMs();
return this.userSessionRepository.find({
where: {
userId,
+ workspaceId,
revokedAt: IsNull(),
expiresAt: MoreThan(now),
lastActiveAt: MoreThan(addMilliseconds(now, -idleTimeoutMs)),
@@ -448,18 +457,21 @@ export class UserSessionService {
return await this.revokeSessionEntity(session, reason);
}
- async revokeSessionByIdForUser({
+ async revokeSessionByIdForUserWorkspace({
sessionId,
userId,
+ workspaceId,
reason,
}: {
sessionId: string;
userId: string;
+ workspaceId: string;
reason: UserSessionRevokedReason;
}): Promise {
const session = await this.userSessionRepository.findOneBy({
id: sessionId,
userId,
+ workspaceId,
});
if (!isDefined(session)) {
@@ -472,12 +484,16 @@ export class UserSessionService {
return await this.revokeSessionEntity(session, reason);
}
+ // workspaceId narrows this to one workspace, which is what the settings panel
+ // wants. Account-wide callers (password change) leave it out on purpose.
async revokeAllSessionsForUser({
userId,
+ workspaceId,
reason,
exceptSessionId,
}: {
userId: string;
+ workspaceId?: string;
reason: UserSessionRevokedReason;
exceptSessionId?: string;
}): Promise {
@@ -491,6 +507,10 @@ export class UserSessionService {
.where('"userId" = :userId', { userId })
.andWhere('"revokedAt" IS NULL');
+ if (isDefined(workspaceId)) {
+ revokingQuery.andWhere('"workspaceId" = :workspaceId', { workspaceId });
+ }
+
if (isDefined(exceptSessionId)) {
revokingQuery.andWhere('"id" != :exceptSessionId', { exceptSessionId });
}
diff --git a/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts
index e25b612ab4..5b1d81bc3a 100644
--- a/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/user-session/user-session.resolver.ts
@@ -8,6 +8,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserSessionDTO } from 'src/engine/core-modules/user-session/dtos/user-session.dto';
import { UserSessionService } from 'src/engine/core-modules/user-session/services/user-session.service';
@@ -16,6 +17,7 @@ import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/t
import { UserSessionCookieService } from 'src/engine/core-modules/user-session/services/user-session-cookie.service';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
+import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
@@ -32,11 +34,22 @@ export class UserSessionResolver {
@UseGuards(UserAuthGuard, NoPermissionGuard)
async currentUserSessions(
@AuthUser() user: AuthContextUser,
+ @AuthWorkspace({ allowUndefined: true }) workspace:
+ | WorkspaceEntity
+ | undefined,
@Context() context: { req: Request },
): Promise {
- const sessions = await this.userSessionService.findActiveSessionsForUser(
- user.id,
- );
+ // UserAuthGuard admits workspace-agnostic credentials, which have no
+ // workspace to scope to. Nothing is in scope rather than everything.
+ if (!isDefined(workspace)) {
+ return [];
+ }
+
+ const sessions =
+ await this.userSessionService.findActiveSessionsForUserWorkspace({
+ userId: user.id,
+ workspaceId: workspace.id,
+ });
const presentedSessionToken =
this.userSessionCookieService.extractSessionTokenFromRequest(context.req);
@@ -53,19 +66,32 @@ export class UserSessionResolver {
@UseGuards(UserAuthGuard, NoPermissionGuard)
async revokeUserSession(
@AuthUser() user: AuthContextUser,
+ @AuthWorkspace({ allowUndefined: true }) workspace:
+ | WorkspaceEntity
+ | undefined,
@Args('userSessionId', { type: () => UUIDScalarType })
userSessionId: string,
@Context() context: { req: Request },
): Promise {
- // Before revoking: afterwards it is no longer active and would not be found.
- const currentSession = await this.resolveCurrentSession(context.req, user);
+ if (!isDefined(workspace)) {
+ return false;
+ }
- const revoked = await this.userSessionService.revokeSessionByIdForUser({
- sessionId: userSessionId,
- userId: user.id,
- reason: UserSessionRevokedReason.UserRevoked,
+ // Before revoking: afterwards it is no longer active and would not be found.
+ const currentSession = await this.resolveCurrentSession({
+ request: context.req,
+ user,
+ workspace,
});
+ const revoked =
+ await this.userSessionService.revokeSessionByIdForUserWorkspace({
+ sessionId: userSessionId,
+ userId: user.id,
+ workspaceId: workspace.id,
+ reason: UserSessionRevokedReason.UserRevoked,
+ });
+
if (
revoked &&
currentSession?.id === userSessionId &&
@@ -78,10 +104,15 @@ export class UserSessionResolver {
}
// A revoked or expired cookie must not decide which sessions survive.
- private async resolveCurrentSession(
- request: Request,
- user: AuthContextUser,
- ): Promise {
+ private async resolveCurrentSession({
+ request,
+ user,
+ workspace,
+ }: {
+ request: Request;
+ user: AuthContextUser;
+ workspace: Pick;
+ }): Promise {
const presentedSessionToken =
this.userSessionCookieService.extractSessionTokenFromRequest(request);
@@ -90,7 +121,10 @@ export class UserSessionResolver {
}
const activeSessions =
- await this.userSessionService.findActiveSessionsForUser(user.id);
+ await this.userSessionService.findActiveSessionsForUserWorkspace({
+ userId: user.id,
+ workspaceId: workspace.id,
+ });
const presentedTokenHash = hashUserSessionToken(presentedSessionToken);
return activeSessions.find(
@@ -102,12 +136,24 @@ export class UserSessionResolver {
@UseGuards(UserAuthGuard, NoPermissionGuard)
async revokeAllOtherUserSessions(
@AuthUser() user: AuthContextUser,
+ @AuthWorkspace({ allowUndefined: true }) workspace:
+ | WorkspaceEntity
+ | undefined,
@Context() context: { req: Request },
): Promise {
- const currentSession = await this.resolveCurrentSession(context.req, user);
+ if (!isDefined(workspace)) {
+ return 0;
+ }
+
+ const currentSession = await this.resolveCurrentSession({
+ request: context.req,
+ user,
+ workspace,
+ });
return await this.userSessionService.revokeAllSessionsForUser({
userId: user.id,
+ workspaceId: workspace.id,
reason: UserSessionRevokedReason.UserRevoked,
exceptSessionId: currentSession?.id,
});
diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/workspace-scoped-user-sessions-api.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/workspace-scoped-user-sessions-api.integration-spec.ts
new file mode 100644
index 0000000000..ee251be093
--- /dev/null
+++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/workspace-scoped-user-sessions-api.integration-spec.ts
@@ -0,0 +1,115 @@
+import {
+ extractSessionCookie,
+ postMetadataOperationWithHeaders,
+ signInWithCookieCapture,
+} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
+import {
+ currentUserIdentityQueryFactory,
+ currentUserSessionsQueryFactory,
+ revokeAllOtherUserSessionsQueryFactory,
+ revokeUserSessionQueryFactory,
+} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
+
+import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
+import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util';
+
+type UserSessionApiEntry = { id: string; isCurrent: boolean };
+
+// Tim is seeded in both apple and yc, which is what makes this provable: one
+// person, two workspaces. Almost nothing in Twenty is account-wide, so a
+// workspace must not surface, or be able to revoke, the sessions this person
+// holds in another workspace.
+describe('workspace-scoped user sessions API (integration)', () => {
+ setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true);
+
+ let appleCookieHeader: string;
+ let ycCookieHeader: string;
+
+ const signInTo = async (workspaceSubdomain: string): Promise => {
+ const response = await signInWithCookieCapture({
+ workspaceSubdomain,
+ originHeader: ALLOWED_ORIGIN,
+ });
+ const sessionCookie = extractSessionCookie(response);
+
+ if (!sessionCookie) {
+ throw new Error(`Expected a session cookie from ${workspaceSubdomain}`);
+ }
+
+ return sessionCookie.cookieHeader;
+ };
+
+ const fetchSessions = async (
+ cookieHeader: string,
+ ): Promise => {
+ const response = await postMetadataOperationWithHeaders(
+ currentUserSessionsQueryFactory(),
+ { originHeader: ALLOWED_ORIGIN, cookieHeader },
+ );
+
+ expect(response.body.errors).toBeUndefined();
+
+ return response.body.data.currentUserSessions;
+ };
+
+ beforeAll(async () => {
+ appleCookieHeader = await signInTo('apple');
+ ycCookieHeader = await signInTo('yc');
+ });
+
+ it('should list only the sessions bound to the workspace the cookie belongs to', async () => {
+ const appleSessionIds = (await fetchSessions(appleCookieHeader)).map(
+ (session) => session.id,
+ );
+ const ycSessionIds = (await fetchSessions(ycCookieHeader)).map(
+ (session) => session.id,
+ );
+
+ expect(appleSessionIds.length).toBeGreaterThan(0);
+ expect(ycSessionIds.length).toBeGreaterThan(0);
+
+ expect(
+ appleSessionIds.filter((sessionId) => ycSessionIds.includes(sessionId)),
+ ).toHaveLength(0);
+ });
+
+ it('should refuse to revoke a session belonging to another workspace', async () => {
+ const ycSessions = await fetchSessions(ycCookieHeader);
+ const ycSessionId = ycSessions[0].id;
+
+ const response = await postMetadataOperationWithHeaders(
+ revokeUserSessionQueryFactory({ userSessionId: ycSessionId }),
+ { originHeader: ALLOWED_ORIGIN, cookieHeader: appleCookieHeader },
+ );
+
+ expect(response.body.errors).toBeDefined();
+
+ // Still usable: the refusal has to be a no-op, not a silent revocation.
+ const ycSessionsAfter = await fetchSessions(ycCookieHeader);
+
+ expect(ycSessionsAfter.map((session) => session.id)).toContain(ycSessionId);
+ });
+
+ it('should leave other workspaces signed in when revoking all other devices', async () => {
+ // A second apple sign-in guarantees there is something to revoke.
+ await signInTo('apple');
+
+ const response = await postMetadataOperationWithHeaders(
+ revokeAllOtherUserSessionsQueryFactory(),
+ { originHeader: ALLOWED_ORIGIN, cookieHeader: appleCookieHeader },
+ );
+
+ expect(response.body.errors).toBeUndefined();
+ expect(response.body.data.revokeAllOtherUserSessions).toBeGreaterThanOrEqual(
+ 1,
+ );
+
+ const ycResponse = await postMetadataOperationWithHeaders(
+ currentUserIdentityQueryFactory(),
+ { originHeader: ALLOWED_ORIGIN, cookieHeader: ycCookieHeader },
+ );
+
+ expect(ycResponse.body.errors).toBeUndefined();
+ expect(ycResponse.body.data.currentUser.email).toBe('tim@apple.dev');
+ });
+});