@@ -46,19 +46,48 @@ export function normalizeApiVersion(value: string | undefined | null): SailPoint
4646}
4747
4848/**
49- * Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme`) or a
50- * full host/URL (`https://acme.api.identitynow.com`, `acme.api.identitynow.com`), stripping any
51- * protocol, path, or version segment the caller may have included.
49+ * Allowed SailPoint API host suffixes. A user-supplied full host must end with one of these; this
50+ * prevents SSRF and PAT-secret disclosure by ensuring the client-credentials request (which carries
51+ * `client_secret`) can only ever be sent to a SailPoint tenant host, never an arbitrary destination.
52+ */
53+ const ALLOWED_HOST_SUFFIXES = [ '.api.identitynow.com' , '.api.identitynowgov.com' ] as const
54+
55+ /**
56+ * Resolves the API + token hosts for a tenant. Accepts either a bare tenant subdomain (`acme` →
57+ * `acme.api.identitynow.com`) or a full SailPoint host/URL (`https://acme.api.identitynow.com`),
58+ * stripping any protocol, path, or version segment. Throws when the resolved host is not a SailPoint
59+ * identitynow.com host - the credentials must never be posted to an attacker-controlled or internal
60+ * host.
5261 */
5362export function resolveSailPointHosts (
5463 tenant : string ,
5564 apiVersion : SailPointApiVersion
5665) : SailPointHosts {
5766 let host = tenant . trim ( ) . replace ( / ^ h t t p s ? : \/ \/ / i, '' )
58- host = host . replace ( / [ / ? # ] .* $ / , '' ) . replace ( / \. + $ / , '' )
59- if ( ! host . includes ( '.' ) ) {
67+ host = host
68+ . replace ( / [ / ? # ] .* $ / , '' )
69+ . replace ( / \. + $ / , '' )
70+ . toLowerCase ( )
71+
72+ if ( ! host ) {
73+ throw new Error ( 'SailPoint tenant is required' )
74+ }
75+
76+ if ( host . includes ( '.' ) ) {
77+ const isAllowed =
78+ / ^ [ a - z 0 - 9 . - ] + $ / . test ( host ) && ALLOWED_HOST_SUFFIXES . some ( ( suffix ) => host . endsWith ( suffix ) )
79+ if ( ! isAllowed ) {
80+ throw new Error (
81+ `SailPoint host "${ host } " is not an allowed identitynow.com host. Enter your tenant name (e.g. "acme") or a full *.api.identitynow.com host.`
82+ )
83+ }
84+ } else {
85+ if ( ! / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 - ] * $ / . test ( host ) ) {
86+ throw new Error ( `Invalid SailPoint tenant "${ tenant } "` )
87+ }
6088 host = `${ host } .api.identitynow.com`
6189 }
90+
6291 return {
6392 host,
6493 apiBaseUrl : `https://${ host } /${ apiVersion } ` ,
@@ -92,10 +121,12 @@ interface CachedToken {
92121}
93122
94123const TOKEN_EXPIRY_BUFFER_MS = 60_000
124+ const MAX_FETCH_RETRIES = 4
95125const tokenCache = new Map < string , CachedToken > ( )
96126
97127function cacheKey ( creds : SailPointServerCredentials ) : string {
98- return `${ creds . tenant } :${ creds . clientId } :${ creds . apiVersion } `
128+ const { host } = resolveSailPointHosts ( creds . tenant , creds . apiVersion )
129+ return `${ host } :${ creds . clientId } :${ creds . apiVersion } `
99130}
100131
101132/** Drops any cached token for these credentials so the next call re-exchanges. */
@@ -112,19 +143,32 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials)
112143 }
113144
114145 const { tokenUrl } = resolveSailPointHosts ( creds . tenant , creds . apiVersion )
115- const response = await fetch ( tokenUrl , {
116- method : 'POST' ,
117- headers : {
118- Accept : 'application/json' ,
119- 'Content-Type' : 'application/x-www-form-urlencoded' ,
120- } ,
121- body : new URLSearchParams ( {
122- grant_type : 'client_credentials' ,
123- client_id : creds . clientId ,
124- client_secret : creds . clientSecret ,
125- } ) . toString ( ) ,
126- cache : 'no-store' ,
127- } )
146+
147+ let attempt = 0
148+ let response : Response
149+ while ( true ) {
150+ response = await fetch ( tokenUrl , {
151+ method : 'POST' ,
152+ headers : {
153+ Accept : 'application/json' ,
154+ 'Content-Type' : 'application/x-www-form-urlencoded' ,
155+ } ,
156+ body : new URLSearchParams ( {
157+ grant_type : 'client_credentials' ,
158+ client_id : creds . clientId ,
159+ client_secret : creds . clientSecret ,
160+ } ) . toString ( ) ,
161+ cache : 'no-store' ,
162+ } )
163+ // The token endpoint shares SailPoint's per-client_id rate limit, so back off on a 429 too.
164+ if ( response . status === 429 && attempt < MAX_FETCH_RETRIES ) {
165+ const retryAfterMs = parseRetryAfter ( response . headers . get ( 'retry-after' ) )
166+ attempt += 1
167+ await sleep ( backoffWithJitter ( attempt , retryAfterMs ) )
168+ continue
169+ }
170+ break
171+ }
128172
129173 const data : unknown = await response . json ( ) . catch ( ( ) => null )
130174 if ( ! response . ok ) {
@@ -134,7 +178,8 @@ export async function getSailPointAccessToken(creds: SailPointServerCredentials)
134178 throw new Error ( 'SailPoint authentication did not return an access token' )
135179 }
136180
137- const expiresInSec = typeof data . expires_in === 'number' ? data . expires_in : 3600
181+ const parsedExpiry = Number ( data . expires_in )
182+ const expiresInSec = Number . isFinite ( parsedExpiry ) && parsedExpiry > 0 ? parsedExpiry : 3600
138183 tokenCache . set ( key , {
139184 token : data . access_token ,
140185 expiresAt : Date . now ( ) + Math . max ( expiresInSec * 1000 - TOKEN_EXPIRY_BUFFER_MS , 0 ) ,
@@ -163,7 +208,7 @@ export async function sailpointFetch(
163208 buildRequest : ( token : string , hosts : SailPointHosts ) => { url : string ; init : RequestInit } ,
164209 options : { maxRetries ?: number } = { }
165210) : Promise < SailPointFetchResult > {
166- const maxRetries = options . maxRetries ?? 4
211+ const maxRetries = options . maxRetries ?? MAX_FETCH_RETRIES
167212 const hosts = resolveSailPointHosts ( creds . tenant , creds . apiVersion )
168213 let attempt = 0
169214 let refreshedOn401 = false
0 commit comments