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' ;
29import { ConfigService } from '@nestjs/config' ;
310import { randomUUID } from 'crypto' ;
411import type { SecretEncryptionMaterial } from '@sentris/shared' ;
512import type { TicketingConnectionConfig } from '@sentris/shared' ;
13+ import type Redis from 'ioredis' ;
614
15+ import { TICKETING_OAUTH_REDIS } from '../common/redis/redis.tokens' ;
716import { TokenEncryptionService } from '../integrations/token.encryption' ;
817import { TicketingRepository } from './ticketing.repository' ;
918import { 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
3844interface OAuthStateCacheEntry {
@@ -42,14 +48,16 @@ interface OAuthStateCacheEntry {
4248 expiresAt : number ;
4349}
4450
51+ const OAUTH_STATE_TTL_SECONDS = 5 * 60 ;
4552const 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