Scope sessions and application authorizations to the workspace (#23843)

Settings / Profile / Devices listed every live session for the account,
so a person who belongs to two workspaces saw all of them from either
one, and "Log out all other devices" signed them out everywhere.

Almost nothing in Twenty is account-wide, and a session belongs to the
workspace its exchange selected, so neither the list nor the revocations
are another workspace's business.

## What was wrong

`currentUserSessions` called `findActiveSessionsForUser(user.id)` with
no workspace filter. Both revoke paths were keyed on `userId` alone, so
`revokeUserSession` could target another workspace's session by id and
`revokeAllOtherUserSessions` cleared every workspace at once.

Sweeping the other resolvers that take `@AuthUser()` turned up the same
shape in the OAuth application authorizations added in #23678:
`findActiveAuthorizationsForUser(userId)` and `revokeAuthorizationById({
authorizationId, userId })`. A grant made in one workspace was listed,
and revocable, from another.

Everything else already pairs `@AuthUser()` with `@AuthWorkspace()`.
`client-config.resolver.ts` is the reference pattern.

## The fix

Both resolvers now take the workspace from the auth context and pass it
down, and the service methods are renamed to say so.

`revokeAllSessionsForUser` keeps `workspaceId` optional on purpose:
`auth.service.ts` uses it on password change, where clearing every
workspace is the intended behaviour.

Sessions with no workspace (the workspace-agnostic ones minted on the
default subdomain, which exists to list workspaces and carry the
auto-login window) now belong to no workspace's list and survive "log
out all other devices".

## Verification

- New integration spec built on Tim's membership of both apple and yc:
the list stays disjoint, a cross-workspace revoke by id is refused and
is a no-op, and revoking all other devices in one workspace leaves the
other signed in
- Mutation-checked by dropping the `workspaceId` from the query, which
fails the isolation test while the two revoke tests still pass,
confirming each assertion targets its own mechanism
- 160 unit tests, 70 integration tests across the session and OAuth
suites

## Also

The devices button drops its danger accent for the plain small variant,
matching Deactivate in `ObjectSettings.tsx`.

## Separate finding, not fixed here

`request.ip` resolves to an internal address behind the Cloudflare /
nginx chain, which is why every row in the screenshot that prompted this
showed the same RFC1918 address. That is an ingress configuration issue
rather than an application one. It does not affect ClickHouse audit
logs, which store no IP, but it does affect the two OAuth rate limiters
that key on `req.ip`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01C6nCVbcb5ZZrz67uvqvMWF)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23843?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-08-06 10:20:45 +02:00
committed by GitHub
parent 26104d47a6
commit ae235d3b24
8 changed files with 347 additions and 53 deletions
@@ -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);
});
});
@@ -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<ApplicationAuthorizationDTO[]> {
// 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<boolean> {
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(
@@ -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<ApplicationAuthorizationEntity[]> {
// 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<ApplicationAuthorizationEntity[]> {
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<boolean> {
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<boolean> {
const { affected } = await this.applicationAuthorizationRepository.update(
@@ -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([]);
@@ -418,15 +418,24 @@ export class UserSessionService {
});
}
async findActiveSessionsForUser(
userId: string,
): Promise<UserSessionEntity[]> {
// 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<UserSessionEntity[]> {
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<boolean> {
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<number> {
@@ -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 });
}
@@ -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<UserSessionDTO[]> {
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<boolean> {
// 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<UserSessionEntity | undefined> {
private async resolveCurrentSession({
request,
user,
workspace,
}: {
request: Request;
user: AuthContextUser;
workspace: Pick<WorkspaceEntity, 'id'>;
}): Promise<UserSessionEntity | undefined> {
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<number> {
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,
});
@@ -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<string> => {
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<UserSessionApiEntry[]> => {
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');
});
});