Skip to content

Commit 13611fc

Browse files
authored
Merge branch 'staging' into somzilla_Issues
2 parents ee26103 + 2f1b964 commit 13611fc

30 files changed

Lines changed: 1828 additions & 5 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# Never commit .env to version control.
55
# ============================================================
66

7+
# Stellar Configuration
8+
STELLAR_HORIZON_TESTNET_URL=https://horizon-testnet.stellar.org
9+
STELLAR_HORIZON_MAINNET_URL=https://horizon.stellar.org
710
# ------------------------------------------------------------
811
# Database
912
# Required: PostgreSQL connection string (Prisma)

prisma/schema.prisma

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ model User {
130130
/// Relation to user's spending limits
131131
spendingLimits SpendingLimit[]
132132
133+
/// Relation to user's refresh tokens
134+
refreshTokens RefreshToken[]
135+
133136
@@index([authId])
134137
@@index([authProvider])
135138
@@index([deletedAt])
@@ -736,6 +739,58 @@ model KeyRotationAuditLog {
736739
@@index([expiresAt])
737740
}
738741

742+
/// Refresh token status lifecycle
743+
enum RefreshTokenStatus {
744+
ACTIVE
745+
ROTATED
746+
REVOKED
747+
EXPIRED
748+
}
749+
750+
/// Refresh token management with automatic rotation on use
751+
model RefreshToken {
752+
id String @id @default(uuid())
753+
754+
/// User reference
755+
userId String
756+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
757+
758+
/// Refresh token hash (never store plaintext)
759+
tokenHash String @unique
760+
761+
/// Rotation chain - previous token in rotation sequence
762+
previousTokenId String?
763+
previousToken RefreshToken? @relation("TokenRotation", fields: [previousTokenId], references: [id])
764+
nextToken RefreshToken[] @relation("TokenRotation")
765+
766+
/// Token status
767+
status RefreshTokenStatus @default(ACTIVE)
768+
769+
/// Expiration timestamp
770+
expiresAt DateTime
771+
772+
/// Usage tracking
773+
lastUsedAt DateTime?
774+
usageCount Int @default(0)
775+
776+
/// Rotation tracking
777+
rotatedAt DateTime?
778+
rotatedReason String?
779+
780+
/// Revocation details
781+
revokedAt DateTime?
782+
revokeReason String?
783+
784+
/// Metadata
785+
createdAt DateTime @default(now())
786+
updatedAt DateTime @updatedAt
787+
788+
@@index([userId, status])
789+
@@index([tokenHash])
790+
@@index([expiresAt])
791+
@@index([previousTokenId])
792+
}
793+
739794
/// Transaction lifecycle states
740795
enum TransactionStatus {
741796
PENDING // Transaction created but not yet submitted to network

src/auth/auth-orchestrator.controller.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ import {
4141
@FeatureFlag('auth_api')
4242
@UseGuards(FeatureFlagGuard)
4343
export class AuthOrchestratorController {
44-
constructor(private readonly authOrchestrator: AuthOrchestrator) {}
44+
constructor(
45+
private readonly authOrchestrator: AuthOrchestrator,
46+
private readonly refreshTokenService: RefreshTokenService,
47+
) {}
4548

4649
/**
4750
* Main authentication endpoint - handles both first-time and returning users.
@@ -306,6 +309,7 @@ export class AuthOrchestratorController {
306309
},
307310
})
308311
@Get('validate/:authId')
312+
@UseGuards(AuthRateLimitGuard)
309313
async validateAuthentication(@Param('authId') authId: string) {
310314
const isValid = await this.authOrchestrator.validateAuthentication(authId);
311315
return { valid: isValid };
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
3+
import { AuthRateLimitService } from './auth-rate-limit.service';
4+
import { PrismaClient } from '../generated/prisma/client';
5+
6+
// Mock Prisma
7+
jest.mock('../generated/prisma/client', () => {
8+
return {
9+
PrismaClient: jest.fn(),
10+
};
11+
});
12+
13+
describe('AuthRateLimitService', () => {
14+
let service: AuthRateLimitService;
15+
let configService: jest.Mocked<ConfigService>;
16+
let prismaMock: any;
17+
18+
beforeEach(async () => {
19+
// Setup Prisma mock
20+
prismaMock = {
21+
rateLimitRecord: {
22+
findUnique: jest.fn(),
23+
deleteMany: jest.fn(),
24+
create: jest.fn(),
25+
update: jest.fn(),
26+
},
27+
};
28+
29+
(PrismaClient as jest.Mock).mockImplementation(() => prismaMock);
30+
31+
// Setup ConfigService mock
32+
configService = {
33+
get: jest.fn((key: string, defaultValue: string) => {
34+
const config: Record<string, string> = {
35+
AUTH_RATE_LIMIT_MAX: '10',
36+
AUTH_RATE_LIMIT_WINDOW_MS: '60000',
37+
};
38+
return config[key] || defaultValue;
39+
}),
40+
} as any;
41+
42+
const module: TestingModule = await Test.createTestingModule({
43+
providers: [
44+
AuthRateLimitService,
45+
{
46+
provide: ConfigService,
47+
useValue: configService,
48+
},
49+
],
50+
}).compile();
51+
52+
service = module.get<AuthRateLimitService>(AuthRateLimitService);
53+
});
54+
55+
afterEach(() => {
56+
jest.clearAllMocks();
57+
});
58+
59+
describe('checkRateLimit', () => {
60+
it('should allow request when within limit', async () => {
61+
const ipAddress = '192.168.1.1';
62+
63+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue(null);
64+
prismaMock.rateLimitRecord.create.mockResolvedValue({
65+
id: 'record-1',
66+
apiKeyId: `auth-rate-limit:${ipAddress}`,
67+
endpoint: 'POST /auth/authenticate',
68+
windowStart: new Date(),
69+
requestCount: 1,
70+
});
71+
72+
const result = await service.checkRateLimit(ipAddress);
73+
74+
expect(result.allowed).toBe(true);
75+
expect(result.remaining).toBe(9);
76+
expect(result.limit).toBe(10);
77+
});
78+
79+
it('should reject request when limit exceeded', async () => {
80+
const ipAddress = '192.168.1.1';
81+
const now = new Date();
82+
const windowStart = new Date(
83+
Math.floor(now.getTime() / 60000) * 60000,
84+
);
85+
86+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue({
87+
id: 'record-1',
88+
apiKeyId: `auth-rate-limit:${ipAddress}`,
89+
endpoint: 'POST /auth/authenticate',
90+
windowStart,
91+
requestCount: 10, // Already at limit
92+
});
93+
94+
const result = await service.checkRateLimit(ipAddress);
95+
96+
expect(result.allowed).toBe(false);
97+
expect(result.remaining).toBe(0);
98+
expect(result.limit).toBe(10);
99+
expect(result.retryAfterSeconds).toBeGreaterThan(0);
100+
});
101+
102+
it('should increment request count for existing record', async () => {
103+
const ipAddress = '192.168.1.1';
104+
const now = new Date();
105+
const windowStart = new Date(
106+
Math.floor(now.getTime() / 60000) * 60000,
107+
);
108+
109+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue({
110+
id: 'record-1',
111+
apiKeyId: `auth-rate-limit:${ipAddress}`,
112+
endpoint: 'POST /auth/authenticate',
113+
windowStart,
114+
requestCount: 5,
115+
});
116+
117+
prismaMock.rateLimitRecord.update.mockResolvedValue({
118+
id: 'record-1',
119+
apiKeyId: `auth-rate-limit:${ipAddress}`,
120+
endpoint: 'POST /auth/authenticate',
121+
windowStart,
122+
requestCount: 6,
123+
});
124+
125+
const result = await service.checkRateLimit(ipAddress);
126+
127+
expect(result.allowed).toBe(true);
128+
expect(result.remaining).toBe(4);
129+
});
130+
131+
it('should clean up old records for same IP', async () => {
132+
const ipAddress = '192.168.1.1';
133+
const now = new Date();
134+
const windowStart = new Date(
135+
Math.floor(now.getTime() / 60000) * 60000,
136+
);
137+
138+
prismaMock.rateLimitRecord.findUnique.mockResolvedValue(null);
139+
prismaMock.rateLimitRecord.deleteMany.mockResolvedValue({ count: 3 });
140+
prismaMock.rateLimitRecord.create.mockResolvedValue({
141+
id: 'record-new',
142+
apiKeyId: `auth-rate-limit:${ipAddress}`,
143+
endpoint: 'POST /auth/authenticate',
144+
windowStart,
145+
requestCount: 1,
146+
});
147+
148+
await service.checkRateLimit(ipAddress);
149+
150+
expect(prismaMock.rateLimitRecord.deleteMany).toHaveBeenCalled();
151+
expect(prismaMock.rateLimitRecord.create).toHaveBeenCalled();
152+
});
153+
154+
it('should handle database errors gracefully', async () => {
155+
const ipAddress = '192.168.1.1';
156+
157+
prismaMock.rateLimitRecord.findUnique.mockRejectedValue(
158+
new Error('Database error'),
159+
);
160+
161+
const result = await service.checkRateLimit(ipAddress);
162+
163+
// Should fail open on error
164+
expect(result.allowed).toBe(true);
165+
expect(result.limit).toBe(10);
166+
expect(result.remaining).toBe(10);
167+
});
168+
});
169+
170+
describe('getConfig', () => {
171+
it('should return rate limit configuration', () => {
172+
const config = service.getConfig();
173+
174+
expect(config.maxRequests).toBe(10);
175+
expect(config.windowMs).toBe(60000);
176+
});
177+
});
178+
});

src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { FeatureFlagGuard } from '../common/feature-flags/feature-flag.guard';
1818
controllers: [AuthOrchestratorController, AuthMetricsController],
1919
providers: [
2020
AuthOrchestrator,
21+
RefreshTokenService,
2122
IdempotencyService,
2223
AuthRateLimitService,
2324
AuthRateLimitGuard,
@@ -27,6 +28,7 @@ import { FeatureFlagGuard } from '../common/feature-flags/feature-flag.guard';
2728
],
2829
exports: [
2930
AuthOrchestrator,
31+
RefreshTokenService,
3032
IdempotencyService,
3133
AuthRateLimitService,
3234
AuthRateLimitGuard,

0 commit comments

Comments
 (0)