Skip to content

Commit ddbcf01

Browse files
committed
fix(sagemaker): Add proactive credential refresh for SSO connections
Add SsoCredentialRefresher that follows the proven SMUS startProactiveCredentialRefresh() pattern: - 10s check interval using setTimeout (handles sleep/resume) - 5min safety buffer before credential expiry - Writes fresh credentials to ~/.aws/.sagemaker-space-profiles persistLocalCredentials() now starts the refresher for SSO connections after the initial credential write. This prevents the ~1h disconnect caused by stale STS credentials in the mapping file. The detached server and SSH ProxyCommand are unchanged - they continue reading credentials from the mapping file, which now stays fresh.
1 parent b96617a commit ddbcf01

2 files changed

Lines changed: 235 additions & 95 deletions

File tree

packages/core/src/awsService/sagemaker/credentialMapping.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,111 @@ import { isSmusSsoConnection } from '../../sagemakerunifiedstudio/auth/model'
2020
const mappingFileName = '.sagemaker-space-profiles'
2121
const 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+
23128
export 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

Comments
 (0)