@@ -20,6 +20,111 @@ import { isSmusSsoConnection } from '../../sagemakerunifiedstudio/auth/model'
2020const mappingFileName = '.sagemaker-space-profiles'
2121const mappingFilePath = path . join ( os . homedir ( ) , '.aws' , mappingFileName )
2222
23+ export interface SsoCachedCredentials {
24+ credentials : {
25+ accessKeyId : string
26+ secretAccessKey : string
27+ sessionToken ?: string
28+ expiration ?: Date
29+ }
30+ }
31+
32+ /**
33+ * Proactive credential refresh for SSO-based SageMaker Space connections.
34+ *
35+ * Follows the same pattern as SMUS `ProjectRoleCredentialsProvider.startProactiveCredentialRefresh()`:
36+ * - Checks every 10 seconds using setTimeout (handles sleep/resume correctly)
37+ * - Refreshes when credentials expire within 5 minutes (safety buffer)
38+ * - Writes fresh credentials to the mapping file so the detached server always reads valid creds
39+ *
40+ * Without this, SSO connections disconnect after ~1 hour when the initial STS credentials expire
41+ * because `persistLocalCredentials()` only writes once at connection time.
42+ */
43+ export class SsoCredentialRefresher {
44+ private refreshTimer ?: ReturnType < typeof setTimeout >
45+ private active = false
46+ readonly checkIntervalMs : number
47+ readonly safetyBufferMs : number
48+
49+ constructor (
50+ private readonly spaceArn : string ,
51+ private readonly getCachedCredentials : ( ) => SsoCachedCredentials | undefined ,
52+ options ?: { checkIntervalMs ?: number ; safetyBufferMs ?: number }
53+ ) {
54+ this . checkIntervalMs = options ?. checkIntervalMs ?? 10_000
55+ this . safetyBufferMs = options ?. safetyBufferMs ?? 5 * 60_000
56+ }
57+
58+ public start ( ) : void {
59+ if ( this . active ) {
60+ return
61+ }
62+ this . active = true
63+ this . scheduleNextCheck ( )
64+ }
65+
66+ public stop ( ) : void {
67+ this . active = false
68+ if ( this . refreshTimer !== undefined ) {
69+ clearTimeout ( this . refreshTimer )
70+ this . refreshTimer = undefined
71+ }
72+ }
73+
74+ public isActive ( ) : boolean {
75+ return this . active
76+ }
77+
78+ private scheduleNextCheck ( ) : void {
79+ if ( ! this . active ) {
80+ return
81+ }
82+ this . refreshTimer = setTimeout ( async ( ) => {
83+ try {
84+ await this . refreshIfNeeded ( )
85+ } catch ( error ) {
86+ getLogger ( ) . error ( `SSO credential refresh failed for ${ this . spaceArn } : %O` , error )
87+ }
88+ if ( this . active ) {
89+ this . scheduleNextCheck ( )
90+ }
91+ } , this . checkIntervalMs )
92+ }
93+
94+ private async refreshIfNeeded ( ) : Promise < void > {
95+ const cached = this . getCachedCredentials ( )
96+ if ( ! cached ) {
97+ return
98+ }
99+
100+ const expiration = cached . credentials . expiration ?. getTime ( )
101+ if ( expiration && expiration - Date . now ( ) > this . safetyBufferMs ) {
102+ return // credentials still fresh
103+ }
104+
105+ // Credentials are expiring soon or have no expiry info - write them to mapping file
106+ await setSpaceSsoProfile (
107+ this . spaceArn ,
108+ cached . credentials . accessKeyId ,
109+ cached . credentials . secretAccessKey ,
110+ cached . credentials . sessionToken ?? ''
111+ )
112+ }
113+ }
114+
115+ /** Active SSO credential refreshers, keyed by spaceArn */
116+ const activeSsoRefreshers = new Map < string , SsoCredentialRefresher > ( )
117+
118+ /**
119+ * Stops all active SSO credential refreshers. Call on extension deactivation or user logout.
120+ */
121+ export function stopAllSsoCredentialRefreshers ( ) : void {
122+ for ( const refresher of activeSsoRefreshers . values ( ) ) {
123+ refresher . stop ( )
124+ }
125+ activeSsoRefreshers . clear ( )
126+ }
127+
23128export async function loadMappings ( ) : Promise < SpaceMappings > {
24129 try {
25130 if ( ! ( await fs . existsFile ( mappingFilePath ) ) ) {
@@ -63,6 +168,19 @@ export async function persistLocalCredentials(spaceArn: string): Promise<void> {
63168 credentials . credentials . secretAccessKey ,
64169 credentials . credentials . sessionToken ?? ''
65170 )
171+
172+ // Start proactive credential refresh for SSO connections.
173+ // Without this, the mapping file goes stale after ~1h (default STS TTL)
174+ // and the detached server reads expired credentials on reconnect.
175+ const existing = activeSsoRefreshers . get ( spaceArn )
176+ if ( existing ) {
177+ existing . stop ( )
178+ }
179+ const refresher = new SsoCredentialRefresher ( spaceArn , ( ) => {
180+ return globals . loginManager . store . credentialsCache [ currentProfileId ] as SsoCachedCredentials | undefined
181+ } )
182+ activeSsoRefreshers . set ( spaceArn , refresher )
183+ refresher . start ( )
66184 } else {
67185 await setSpaceIamProfile ( spaceArn , currentProfileId )
68186 }
0 commit comments