Skip to content

Commit 28f3f56

Browse files
committed
fix(ticketing): store oauth state in redis
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent e16e6dd commit 28f3f56

4 files changed

Lines changed: 192 additions & 26 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const PROVISIONING_REDIS = Symbol('PROVISIONING_REDIS');
22
export const INSTANCE_HEARTBEAT_REDIS = Symbol('INSTANCE_HEARTBEAT_REDIS');
3+
export const TICKETING_OAUTH_REDIS = Symbol('TICKETING_OAUTH_REDIS');

backend/src/ticketing/__tests__/ticketing.service.spec.ts

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,40 @@ function makeTicketLink(overrides: Record<string, unknown> = {}) {
6363
};
6464
}
6565

66+
class MockRedis {
67+
private readonly kv = new Map<string, string>();
68+
private readonly ttls = new Map<string, number>();
69+
70+
async set(key: string, value: string, mode?: string, ttl?: number): Promise<string> {
71+
this.kv.set(key, value);
72+
if (mode === 'EX' && ttl) {
73+
this.ttls.set(key, ttl);
74+
}
75+
return 'OK';
76+
}
77+
78+
async get(key: string): Promise<string | null> {
79+
return this.kv.get(key) ?? null;
80+
}
81+
82+
async del(key: string): Promise<number> {
83+
const existed = this.kv.has(key);
84+
this.kv.delete(key);
85+
this.ttls.delete(key);
86+
return existed ? 1 : 0;
87+
}
88+
89+
async quit(): Promise<void> {}
90+
91+
getTtl(key: string): number | undefined {
92+
return this.ttls.get(key);
93+
}
94+
95+
has(key: string): boolean {
96+
return this.kv.has(key);
97+
}
98+
}
99+
66100
// ---------------------------------------------------------------------------
67101
// Mocks
68102
// ---------------------------------------------------------------------------
@@ -135,12 +169,13 @@ function createMocks() {
135169
return { repoMock, adapterMock, encryptionMock, configMock };
136170
}
137171

138-
function createService(mocks: ReturnType<typeof createMocks>) {
172+
function createService(mocks: ReturnType<typeof createMocks>, redis: MockRedis | null = null) {
139173
return new TicketingService(
140174
mocks.repoMock as unknown as TicketingRepository,
141175
mocks.adapterMock as unknown as JiraAdapter,
142176
mocks.encryptionMock as unknown as TokenEncryptionService,
143177
mocks.configMock as any,
178+
redis as any,
144179
);
145180
}
146181

@@ -189,8 +224,12 @@ describe('TicketingService', () => {
189224
// -----------------------------------------------------------------------
190225

191226
describe('startOAuthFlow', () => {
192-
it('returns authorization URL with correct scopes', () => {
193-
const result = service.startOAuthFlow(ORG_ID, USER_ID, 'https://app.example.com/callback');
227+
it('returns authorization URL with correct scopes', async () => {
228+
const result = await service.startOAuthFlow(
229+
ORG_ID,
230+
USER_ID,
231+
'https://app.example.com/callback',
232+
);
194233

195234
expect(result.authorizationUrl).toContain('https://auth.atlassian.com/authorize');
196235
expect(result.authorizationUrl).toContain('read%3Ajira-work');
@@ -200,22 +239,26 @@ describe('TicketingService', () => {
200239
expect(typeof result.state).toBe('string');
201240
});
202241

203-
it('returns a UUID state parameter', () => {
204-
const result = service.startOAuthFlow(ORG_ID, USER_ID, 'https://app.example.com/callback');
242+
it('returns a UUID state parameter', async () => {
243+
const result = await service.startOAuthFlow(
244+
ORG_ID,
245+
USER_ID,
246+
'https://app.example.com/callback',
247+
);
205248

206249
// UUID v4 format check
207250
expect(result.state).toMatch(
208251
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
209252
);
210253
});
211254

212-
it('throws when Jira OAuth is not configured', () => {
255+
it('throws when Jira OAuth is not configured', async () => {
213256
mocks.configMock.get.mockReturnValue('');
214257
const svc = createService(mocks);
215258

216-
expect(() => svc.startOAuthFlow(ORG_ID, USER_ID, 'https://app.example.com/callback')).toThrow(
217-
BadRequestException,
218-
);
259+
await expect(
260+
svc.startOAuthFlow(ORG_ID, USER_ID, 'https://app.example.com/callback'),
261+
).rejects.toThrow(BadRequestException);
219262
});
220263
});
221264

@@ -232,7 +275,11 @@ describe('TicketingService', () => {
232275

233276
it('throws when no accessible Jira cloud resources found', async () => {
234277
// Set up a valid state first
235-
const { state } = service.startOAuthFlow(ORG_ID, USER_ID, 'https://app.example.com/callback');
278+
const { state } = await service.startOAuthFlow(
279+
ORG_ID,
280+
USER_ID,
281+
'https://app.example.com/callback',
282+
);
236283

237284
// Mock the code exchange
238285
const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValueOnce(
@@ -250,6 +297,36 @@ describe('TicketingService', () => {
250297

251298
fetchSpy.mockRestore();
252299
});
300+
301+
it('consumes OAuth state across service instances through Redis', async () => {
302+
const redis = new MockRedis();
303+
const startService = createService(mocks, redis);
304+
const callbackService = createService(mocks, redis);
305+
306+
const { state } = await startService.startOAuthFlow(
307+
ORG_ID,
308+
USER_ID,
309+
'https://app.example.com/callback',
310+
);
311+
const redisKey = `sentris:ticketing:oauth-state:${state}`;
312+
313+
expect(redis.getTtl(redisKey)).toBe(300);
314+
315+
const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValueOnce(
316+
new Response(
317+
JSON.stringify({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }),
318+
{ status: 200 },
319+
),
320+
);
321+
322+
await expect(callbackService.handleOAuthCallback('code-123', state)).resolves.toEqual({
323+
success: true,
324+
});
325+
326+
expect(redis.has(redisKey)).toBe(false);
327+
328+
fetchSpy.mockRestore();
329+
});
253330
});
254331

255332
// -----------------------------------------------------------------------

backend/src/ticketing/ticketing.module.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { forwardRef, Module } from '@nestjs/common';
1+
import { forwardRef, Logger, Module } from '@nestjs/common';
2+
import { ConfigModule, ConfigService } from '@nestjs/config';
3+
import Redis from 'ioredis';
24

5+
import { TICKETING_OAUTH_REDIS } from '../common/redis/redis.tokens';
6+
import type { RedisConfig } from '../config';
37
import { DatabaseModule } from '../database/database.module';
48
import { FindingTriageModule } from '../findings/finding-triage.module';
59
import { IntegrationsModule } from '../integrations/integrations.module';
@@ -12,9 +16,31 @@ import { JiraWebhookController } from './jira/jira-webhook.controller';
1216
import { JiraWebhookService } from './jira/jira-webhook.service';
1317

1418
@Module({
15-
imports: [DatabaseModule, IntegrationsModule, forwardRef(() => FindingTriageModule)],
19+
imports: [
20+
ConfigModule,
21+
DatabaseModule,
22+
IntegrationsModule,
23+
forwardRef(() => FindingTriageModule),
24+
],
1625
controllers: [TicketingController, JiraWebhookController],
1726
providers: [
27+
{
28+
provide: TICKETING_OAUTH_REDIS,
29+
useFactory: (configService: ConfigService) => {
30+
const redis = configService.get<RedisConfig>('redis');
31+
const url = redis?.url ?? redis?.terminalUrl;
32+
if (!url) {
33+
new Logger('TicketingModule').warn('Redis URL not set; ticketing OAuth state disabled');
34+
return null;
35+
}
36+
const client = new Redis(url);
37+
client.on('error', (err) =>
38+
new Logger('TicketingModule').warn(`TICKETING_OAUTH_REDIS error: ${err.message}`),
39+
);
40+
return client;
41+
},
42+
inject: [ConfigService],
43+
},
1844
TicketingService,
1945
TicketingRepository,
2046
TicketingListenerService,

backend/src/ticketing/ticketing.service.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
1+
import {
2+
BadRequestException,
3+
Inject,
4+
Injectable,
5+
Logger,
6+
NotFoundException,
7+
OnModuleDestroy,
8+
} from '@nestjs/common';
29
import { ConfigService } from '@nestjs/config';
310
import { randomUUID } from 'crypto';
411
import type { SecretEncryptionMaterial } from '@sentris/shared';
512
import type { TicketingConnectionConfig } from '@sentris/shared';
13+
import type Redis from 'ioredis';
614

15+
import { TICKETING_OAUTH_REDIS } from '../common/redis/redis.tokens';
716
import { TokenEncryptionService } from '../integrations/token.encryption';
817
import { TicketingRepository } from './ticketing.repository';
918
import { JiraAdapter, JiraApiError } from './jira/jira.adapter';
@@ -29,10 +38,7 @@ export interface ConnectionStatus {
2938
}
3039

3140
// ---------------------------------------------------------------------------
32-
// OAuth state in-memory cache (TTL 5 minutes)
33-
// TODO(S2-4): Migrate OAuth state to Redis for multi-instance support.
34-
// Use SET with EX (5min TTL) for storage, GET+DEL for consumption.
35-
// See PROVISIONING_REDIS token in common/redis/redis.tokens.ts for pattern.
41+
// OAuth state cache (Redis-backed with local fallback, TTL 5 minutes)
3642
// ---------------------------------------------------------------------------
3743

3844
interface OAuthStateCacheEntry {
@@ -42,14 +48,16 @@ interface OAuthStateCacheEntry {
4248
expiresAt: number;
4349
}
4450

51+
const OAUTH_STATE_TTL_SECONDS = 5 * 60;
4552
const OAUTH_STATE_TTL_MS = 5 * 60 * 1000;
53+
const OAUTH_STATE_KEY_PREFIX = 'sentris:ticketing:oauth-state:';
4654

4755
// ---------------------------------------------------------------------------
4856
// Service
4957
// ---------------------------------------------------------------------------
5058

5159
@Injectable()
52-
export class TicketingService {
60+
export class TicketingService implements OnModuleDestroy {
5361
private readonly logger = new Logger(TicketingService.name);
5462
private readonly jiraClientId: string;
5563
private readonly jiraClientSecret: string;
@@ -62,12 +70,21 @@ export class TicketingService {
6270
private readonly jiraAdapter: JiraAdapter,
6371
private readonly encryption: TokenEncryptionService,
6472
configService: ConfigService,
73+
@Inject(TICKETING_OAUTH_REDIS) private readonly oauthStateRedis: Redis | null,
6574
) {
6675
this.jiraClientId = configService.get<string>('JIRA_CLIENT_ID', '');
6776
this.jiraClientSecret = configService.get<string>('JIRA_CLIENT_SECRET', '');
6877
this.jiraCallbackUrl = configService.get<string>('JIRA_CALLBACK_URL', '');
6978
}
7079

80+
async onModuleDestroy(): Promise<void> {
81+
try {
82+
await this.oauthStateRedis?.quit();
83+
} catch {
84+
// ignore
85+
}
86+
}
87+
7188
// ---------------------------------------------------------------------------
7289
// Connection status
7390
// ---------------------------------------------------------------------------
@@ -98,20 +115,19 @@ export class TicketingService {
98115
// OAuth 2.0 (3LO)
99116
// ---------------------------------------------------------------------------
100117

101-
startOAuthFlow(
118+
async startOAuthFlow(
102119
organizationId: string,
103120
userId: string,
104121
redirectUri: string,
105-
): OAuthConnectResponse {
122+
): Promise<OAuthConnectResponse> {
106123
this.requireJiraConfig();
107124
const state = randomUUID();
108-
this.oauthStateCache.set(state, {
125+
await this.storeOAuthState(state, {
109126
organizationId,
110127
userId,
111128
redirectUri,
112129
expiresAt: Date.now() + OAUTH_STATE_TTL_MS,
113130
});
114-
this.cleanExpiredStates();
115131

116132
const url = new URL('https://auth.atlassian.com/authorize');
117133
url.searchParams.set('audience', 'api.atlassian.com');
@@ -130,12 +146,10 @@ export class TicketingService {
130146

131147
async handleOAuthCallback(code: string, state: string): Promise<{ success: boolean }> {
132148
this.requireJiraConfig();
133-
const cached = this.oauthStateCache.get(state);
134-
if (!cached || cached.expiresAt < Date.now()) {
135-
this.oauthStateCache.delete(state ?? '');
149+
const cached = await this.consumeOAuthState(state);
150+
if (!cached) {
136151
throw new BadRequestException('Invalid or expired OAuth state');
137152
}
138-
this.oauthStateCache.delete(state);
139153
const { organizationId, userId } = cached;
140154

141155
const tokenResponse = await this.exchangeCodeForTokens(code);
@@ -483,4 +497,52 @@ export class TicketingService {
483497
if (entry.expiresAt < now) this.oauthStateCache.delete(key);
484498
}
485499
}
500+
501+
private async storeOAuthState(state: string, entry: OAuthStateCacheEntry): Promise<void> {
502+
if (this.oauthStateRedis) {
503+
try {
504+
await this.oauthStateRedis.set(
505+
this.oauthStateKey(state),
506+
JSON.stringify(entry),
507+
'EX',
508+
OAUTH_STATE_TTL_SECONDS,
509+
);
510+
return;
511+
} catch (error) {
512+
this.logger.warn(`Failed to store Jira OAuth state in Redis: ${error}`);
513+
}
514+
}
515+
516+
this.oauthStateCache.set(state, entry);
517+
this.cleanExpiredStates();
518+
}
519+
520+
private async consumeOAuthState(state: string): Promise<OAuthStateCacheEntry | null> {
521+
if (this.oauthStateRedis) {
522+
try {
523+
const key = this.oauthStateKey(state);
524+
const raw = await this.oauthStateRedis.get(key);
525+
if (raw) {
526+
await this.oauthStateRedis.del(key);
527+
const parsed = JSON.parse(raw) as OAuthStateCacheEntry;
528+
if (parsed.expiresAt < Date.now()) return null;
529+
return parsed;
530+
}
531+
} catch (error) {
532+
this.logger.warn(`Failed to consume Jira OAuth state from Redis: ${error}`);
533+
}
534+
}
535+
536+
const cached = this.oauthStateCache.get(state);
537+
if (!cached || cached.expiresAt < Date.now()) {
538+
this.oauthStateCache.delete(state);
539+
return null;
540+
}
541+
this.oauthStateCache.delete(state);
542+
return cached;
543+
}
544+
545+
private oauthStateKey(state: string): string {
546+
return `${OAUTH_STATE_KEY_PREFIX}${state}`;
547+
}
486548
}

0 commit comments

Comments
 (0)