Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/atproto-identity/atproto-identity-recovery.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AtprotoIdentityRecoveryService } from './atproto-identity-recovery.serv
import { UserAtprotoIdentityService } from '../user-atproto-identity/user-atproto-identity.service';
import { PdsAccountService } from '../pds/pds-account.service';
import { PdsCredentialService } from '../pds/pds-credential.service';
import { PdsSessionService } from '../pds/pds-session.service';
import { UserService } from '../user/user.service';
import { UserAtprotoIdentityEntity } from '../user-atproto-identity/infrastructure/persistence/relational/entities/user-atproto-identity.entity';

Expand All @@ -14,6 +15,7 @@ describe('AtprotoIdentityRecoveryService', () => {
let userAtprotoIdentityService: jest.Mocked<UserAtprotoIdentityService>;
let pdsAccountService: jest.Mocked<PdsAccountService>;
let pdsCredentialService: jest.Mocked<PdsCredentialService>;
let pdsSessionService: jest.Mocked<PdsSessionService>;
let userService: jest.Mocked<UserService>;

const mockUser = {
Expand Down Expand Up @@ -59,6 +61,10 @@ describe('AtprotoIdentityRecoveryService', () => {
encrypt: jest.fn(),
};

const mockPdsSessionService = {
invalidateSession: jest.fn(),
};

const mockUserService = {
findByUlid: jest.fn(),
};
Expand All @@ -85,6 +91,10 @@ describe('AtprotoIdentityRecoveryService', () => {
provide: PdsCredentialService,
useValue: mockPdsCredentialService,
},
{
provide: PdsSessionService,
useValue: mockPdsSessionService,
},
{
provide: UserService,
useValue: mockUserService,
Expand All @@ -106,6 +116,7 @@ describe('AtprotoIdentityRecoveryService', () => {
userAtprotoIdentityService = module.get(UserAtprotoIdentityService);
pdsAccountService = module.get(PdsAccountService);
pdsCredentialService = module.get(PdsCredentialService);
pdsSessionService = module.get(PdsSessionService);
userService = module.get(UserService);
});

Expand Down Expand Up @@ -526,15 +537,23 @@ describe('AtprotoIdentityRecoveryService', () => {
// Act
await service.completeTakeOwnership('test-tenant', mockUser.ulid);

// Assert - credentials cleared and marked as non-custodial
// Assert - credentials cleared, marked non-custodial, pending-handoff
// marker resolved in the same write
expect(userAtprotoIdentityService.update).toHaveBeenCalledWith(
'test-tenant',
mockIdentityEntity.id,
{
pdsCredentials: null,
isCustodial: false,
takeOwnershipStatus: null,
},
);

// Assert - cached session invalidated so it cannot serve a stale agent
expect(pdsSessionService.invalidateSession).toHaveBeenCalledWith(
'test-tenant',
mockIdentityEntity.did,
);
});

it('should throw BadRequestException when no identity exists', async () => {
Expand All @@ -547,7 +566,7 @@ describe('AtprotoIdentityRecoveryService', () => {
).rejects.toThrow(BadRequestException);
});

it('should throw BadRequestException when identity is already non-custodial', async () => {
it('should succeed as a no-op when identity is already non-custodial', async () => {
// Arrange
const nonCustodialIdentity = {
...mockIdentityEntity,
Expand All @@ -557,10 +576,12 @@ describe('AtprotoIdentityRecoveryService', () => {
nonCustodialIdentity as UserAtprotoIdentityEntity,
);

// Act & Assert
// Act & Assert - idempotent: resolves without touching the identity
await expect(
service.completeTakeOwnership('test-tenant', mockUser.ulid),
).rejects.toThrow(BadRequestException);
).resolves.toBeUndefined();
expect(userAtprotoIdentityService.update).not.toHaveBeenCalled();
expect(pdsSessionService.invalidateSession).not.toHaveBeenCalled();
});
});
});
23 changes: 18 additions & 5 deletions src/atproto-identity/atproto-identity-recovery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { UserAtprotoIdentityService } from '../user-atproto-identity/user-atprot
import { UserAtprotoIdentityEntity } from '../user-atproto-identity/infrastructure/persistence/relational/entities/user-atproto-identity.entity';
import { PdsAccountService } from '../pds/pds-account.service';
import { PdsCredentialService } from '../pds/pds-credential.service';
import { PdsSessionService } from '../pds/pds-session.service';
import { isServiceNotConfiguredError } from '../pds/pds-error-detection';
import { UserService } from '../user/user.service';
import { AllConfigType } from '../config/config.type';
Expand Down Expand Up @@ -40,6 +41,7 @@ export class AtprotoIdentityRecoveryService {
private readonly userAtprotoIdentityService: UserAtprotoIdentityService,
private readonly pdsAccountService: PdsAccountService,
private readonly pdsCredentialService: PdsCredentialService,
private readonly pdsSessionService: PdsSessionService,
private readonly userService: UserService,
private readonly configService: ConfigService<AllConfigType>,
@Inject(REQUEST) private readonly request?: any,
Expand Down Expand Up @@ -248,11 +250,16 @@ export class AtprotoIdentityRecoveryService {
}

/**
* Complete take ownership: user confirms they've set password, we clear credentials.
* Complete take ownership: clear credentials, mark non-custodial, and
* invalidate any cached PDS session so it cannot serve a stale agent.
*
* Idempotent: an already-non-custodial identity is a successful no-op, so
* clients that call this after the server has already ended custody (the
* platform's reset flow) see success rather than a 400.
*
* @param tenantId - The tenant ID
* @param userUlid - The user's ULID
* @throws BadRequestException if no custodial identity exists
* @throws BadRequestException if the user has no AT Protocol identity
*/
async completeTakeOwnership(
tenantId: string,
Expand All @@ -266,17 +273,23 @@ export class AtprotoIdentityRecoveryService {
throw new BadRequestException('User has no AT Protocol identity');
}
if (!identity.isCustodial) {
throw new BadRequestException(
'User already owns their AT Protocol identity',
this.logger.log(
`Take ownership already complete for user ${userUlid}: identity is non-custodial`,
);
return;
}

// Clear credentials and mark as non-custodial
// Clear credentials and mark as non-custodial; the pending-handoff
// marker is resolved by the same write
await this.userAtprotoIdentityService.update(tenantId, identity.id, {
pdsCredentials: null,
isCustodial: false,
takeOwnershipStatus: null,
});

// The cached session was minted from the credentials we just cleared
await this.pdsSessionService.invalidateSession(tenantId, identity.did);

this.logger.log(
`Completed take ownership for user ${userUlid}: cleared credentials, now non-custodial`,
);
Expand Down
Loading
Loading