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
119 changes: 116 additions & 3 deletions src/auth/services/atproto-service-auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AtprotoServiceAuthService } from './atproto-service-auth.service';
import { UserAtprotoIdentityService } from '../../user-atproto-identity/user-atproto-identity.service';
import { AuthService } from '../auth.service';
import { UserService } from '../../user/user.service';
import { ElastiCacheService } from '../../elasticache/elasticache.service';
import { IdResolver } from '@atproto/identity';
import { verifySignature } from '@atproto/crypto';

Expand Down Expand Up @@ -36,6 +37,7 @@ describe('AtprotoServiceAuthService', () => {
validateSocialLogin: jest.Mock;
};
let mockUserService: { findByUlid: jest.Mock };
let mockElastiCacheService: { get: jest.Mock; set: jest.Mock };

// Helper: create a JWT with given claims
function makeJwt(
Expand Down Expand Up @@ -80,6 +82,12 @@ describe('AtprotoServiceAuthService', () => {
findByUlid: jest.fn(),
};

mockElastiCacheService = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
isConnected: jest.fn().mockReturnValue(true),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
AtprotoServiceAuthService,
Expand All @@ -90,6 +98,7 @@ describe('AtprotoServiceAuthService', () => {
},
{ provide: AuthService, useValue: mockAuthService },
{ provide: UserService, useValue: mockUserService },
{ provide: ElastiCacheService, useValue: mockElastiCacheService },
],
}).compile();

Expand Down Expand Up @@ -371,9 +380,9 @@ describe('AtprotoServiceAuthService', () => {

const token = makeJwt(validHeader, validPayload);

await expect(
service.verifyAndExchange(token, 'tenant1'),
).rejects.toThrow('connection refused');
await expect(service.verifyAndExchange(token, 'tenant1')).rejects.toThrow(
'connection refused',
);
});

it('should use DID as pdsUrl fallback when resolvedPdsUrl is undefined', async () => {
Expand Down Expand Up @@ -586,6 +595,110 @@ describe('AtprotoServiceAuthService', () => {
});
});

describe('replay protection', () => {
// Helper to set up mocks for a successful verification pipeline
function setupSuccessfulVerification() {
MockedIdResolver.mockImplementation(() => ({
did: {
resolveAtprotoData: jest.fn().mockResolvedValue({
did: 'did:plc:testuser123',
signingKey: 'did:key:z1234mockkey',
handle: 'test.bsky.social',
pds: 'https://pds.example.com',
}),
},
}));

mockedVerifySignature.mockResolvedValue(true);

mockIdentityService.findByDid.mockResolvedValue({
userUlid: 'user-ulid-123',
did: 'did:plc:testuser123',
});

mockUserService.findByUlid.mockResolvedValue({
id: 1,
ulid: 'user-ulid-123',
slug: 'testuser',
role: { id: 2 },
});

mockAuthService.createLoginSession.mockResolvedValue({
token: 'jwt-token',
refreshToken: 'refresh-token',
tokenExpires: 12345,
user: {},
});
}

it('should reject a replayed token', async () => {
setupSuccessfulVerification();

const token = makeJwt(validHeader, validPayload);

// First call succeeds
await service.verifyAndExchange(token, 'tenant1');

// Simulate Redis returning the stored hash on second call
mockElastiCacheService.get.mockResolvedValueOnce('1');

// Second call with same token should be rejected
await expect(
service.verifyAndExchange(token, 'tenant1'),
).rejects.toThrow(new UnauthorizedException('Token already used'));
});

it('should allow different tokens for the same user', async () => {
setupSuccessfulVerification();

const token1 = makeJwt(validHeader, {
...validPayload,
exp: Math.floor(Date.now() / 1000) + 120,
});
const token2 = makeJwt(validHeader, {
...validPayload,
exp: Math.floor(Date.now() / 1000) + 180,
});

// Both calls should succeed (Redis returns null for both = not seen before)
const result1 = await service.verifyAndExchange(token1, 'tenant1');
const result2 = await service.verifyAndExchange(token2, 'tenant1');

expect(result1).toBeDefined();
expect(result2).toBeDefined();
// set should have been called twice (once per token)
expect(mockElastiCacheService.set).toHaveBeenCalledTimes(2);
});

it('should reject when Redis is unavailable (fail-closed)', async () => {
setupSuccessfulVerification();

// Simulate Redis being down
mockElastiCacheService.isConnected.mockReturnValue(false);

const token = makeJwt(validHeader, validPayload);

await expect(
service.verifyAndExchange(token, 'tenant1'),
).rejects.toThrow(
new UnauthorizedException('Service temporarily unavailable'),
);
});

it('should include tenantId in the replay key', async () => {
setupSuccessfulVerification();

const token = makeJwt(validHeader, validPayload);
await service.verifyAndExchange(token, 'my-tenant');

expect(mockElastiCacheService.set).toHaveBeenCalledWith(
expect.stringContaining('service-auth:used:my-tenant:'),
'1',
300,
);
});
});

it('should return login tokens when JWT is valid and user exists', async () => {
MockedIdResolver.mockImplementation(() => ({
did: {
Expand Down
30 changes: 28 additions & 2 deletions src/auth/services/atproto-service-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ import {
forwardRef,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHash } from 'crypto';
import { IdResolver } from '@atproto/identity';
import { verifySignature } from '@atproto/crypto';
import { UserAtprotoIdentityService } from '../../user-atproto-identity/user-atproto-identity.service';
import { AuthService } from '../auth.service';
import { UserService } from '../../user/user.service';
import { ElastiCacheService } from '../../elasticache/elasticache.service';
import { LoginResponseDto } from '../dto/login-response.dto';
import { AuthProvidersEnum } from '../auth-providers.enum';

@Injectable()
export class AtprotoServiceAuthService {
private readonly logger = new Logger(AtprotoServiceAuthService.name);
// eslint-disable-next-line @typescript-eslint/no-explicit-any

private idResolver: any;

constructor(
Expand All @@ -28,9 +30,9 @@ export class AtprotoServiceAuthService {
private readonly authService: AuthService,
@Inject(forwardRef(() => UserService))
private readonly userService: UserService,
private readonly elastiCacheService: ElastiCacheService,
) {}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private getIdResolver(): any {
if (!this.idResolver) {
const didPlcUrl = this.configService.get<string>('DID_PLC_URL', {
Expand Down Expand Up @@ -178,6 +180,30 @@ export class AtprotoServiceAuthService {
throw new UnauthorizedException('Invalid signature');
}

// Step 4b: Replay protection - reject tokens that have already been used
// Fail-closed: if Redis is unavailable, reject rather than allowing replays.
if (!this.elastiCacheService.isConnected()) {
this.logger.error(
'Service auth rejected: Redis unavailable for replay protection',
);
throw new UnauthorizedException('Service temporarily unavailable');
}

const tokenHash = createHash('sha256').update(token).digest('hex');
const replayKey = `service-auth:used:${tenantId}:${tokenHash}`;

const alreadyUsed = await this.elastiCacheService.get<string>(replayKey);
if (alreadyUsed) {
this.logger.warn(`Service auth rejected: replayed token for ${iss}`);
throw new UnauthorizedException('Token already used');
}

await this.elastiCacheService.set(
replayKey,
'1',
MAX_TOKEN_LIFETIME_SECONDS,
);

// Step 5: Look up user by DID
const identity = await this.userAtprotoIdentityService.findByDid(
tenantId,
Expand Down