Skip to content

Commit e669bb2

Browse files
authored
Implement OAuthClientCredsHelper for token management
This script includes functionality for OAuth 2.0 client-credentials token acquisition, caching, and automatic token injection and refresh for RESTMessageV2 calls.
1 parent ea8c169 commit e669bb2

File tree

1 file changed

+147
-0
lines changed

1 file changed

+147
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Script Include: OAuthClientCredsHelper
3+
* Purpose: Perform OAuth 2.0 client-credentials token acquisition and caching,
4+
* and wrap RESTMessageV2 calls with automatic token injection and refresh.
5+
*
6+
* SECURITY: Store clientSecret in a secure location (Credentials or encrypted property).
7+
*/
8+
var OAuthClientCredsHelper = Class.create();
9+
OAuthClientCredsHelper.prototype = {
10+
initialize: function() {},
11+
12+
/**
13+
* Execute an API request with Bearer token and one-shot auto-refresh on 401.
14+
* Returns an object {status, body, headers, refreshed:Boolean}
15+
*/
16+
request: function(options) {
17+
var token = this.getToken(options); // may fetch or use cached
18+
19+
var res = this._call(options, token);
20+
if (res.status !== 401) return res;
21+
22+
// If 401, refresh token once and retry
23+
var refreshed = this.getToken(this._forceRefresh(options));
24+
var retry = this._call(options, refreshed);
25+
retry.refreshed = true;
26+
return retry;
27+
},
28+
29+
/**
30+
* Get a cached token or fetch a new one if expired/near-expiry.
31+
* Returns the access_token string.
32+
*/
33+
getToken: function(options) {
34+
this._assert(['tokenUrl', 'clientId', 'clientSecret', 'propPrefix'], options);
35+
var now = new Date().getTime();
36+
37+
var tokenKey = options.propPrefix + '.access_token';
38+
var expiryKey = options.propPrefix + '.expires_at';
39+
40+
var cached = gs.getProperty(tokenKey, '');
41+
var expiresAt = parseInt(gs.getProperty(expiryKey, '0'), 10) || 0;
42+
43+
// 60-second safety buffer
44+
var bufferMs = 60 * 1000;
45+
if (cached && expiresAt > (now + bufferMs)) {
46+
return cached;
47+
}
48+
49+
// Need a fresh token
50+
var fresh = this._fetchToken(options);
51+
gs.setProperty(tokenKey, fresh.access_token);
52+
gs.setProperty(expiryKey, String(fresh.expires_at));
53+
return fresh.access_token;
54+
},
55+
56+
// ------------------ internals ------------------
57+
58+
_call: function(options, accessToken) {
59+
var r = new sn_ws.RESTMessageV2();
60+
r.setEndpoint(options.resource);
61+
r.setHttpMethod((options.method || 'GET').toUpperCase());
62+
r.setRequestHeader('Authorization', 'Bearer ' + accessToken);
63+
// Extra headers
64+
Object.keys(options.headers || {}).forEach(function(k) {
65+
r.setRequestHeader(k, options.headers[k]);
66+
});
67+
68+
if (options.body && /^(POST|PUT|PATCH)$/i.test(options.method || 'GET')) {
69+
r.setRequestBody(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
70+
// set content type if caller didn't
71+
if (!options.headers || !options.headers['Content-Type']) {
72+
r.setRequestHeader('Content-Type', 'application/json');
73+
}
74+
}
75+
76+
var resp = r.execute();
77+
return {
78+
status: resp.getStatusCode(),
79+
body: resp.getBody(),
80+
headers: this._collectHeaders(resp),
81+
refreshed: false
82+
};
83+
},
84+
85+
_fetchToken: function(options) {
86+
// RFC 6749 client-credentials: POST x-www-form-urlencoded
87+
var r = new sn_ws.RESTMessageV2();
88+
r.setEndpoint(options.tokenUrl);
89+
r.setHttpMethod('POST');
90+
r.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
91+
92+
var params = [
93+
'grant_type=client_credentials',
94+
'client_id=' + encodeURIComponent(options.clientId),
95+
'client_secret=' + encodeURIComponent(options.clientSecret)
96+
];
97+
if (options.scope) params.push('scope=' + encodeURIComponent(options.scope));
98+
if (options.audience) params.push('audience=' + encodeURIComponent(options.audience));
99+
100+
r.setRequestBody(params.join('&'));
101+
102+
var resp = r.execute();
103+
var status = resp.getStatusCode();
104+
var body = resp.getBody();
105+
if (status < 200 || status >= 300) {
106+
throw 'Token endpoint HTTP ' + status + ': ' + body;
107+
}
108+
109+
var json;
110+
try { json = JSON.parse(body); }
111+
catch (e) { throw 'Invalid token JSON: ' + e.message; }
112+
113+
var access = json.access_token;
114+
var ttlSec = Number(json.expires_in || 3600);
115+
if (!access) throw 'Token response missing access_token';
116+
117+
var now = new Date().getTime();
118+
var expiresAt = now + (ttlSec * 1000);
119+
return { access_token: access, expires_at: expiresAt };
120+
},
121+
122+
_collectHeaders: function(resp) {
123+
var map = {};
124+
var names = resp.getAllHeaders();
125+
for (var i = 0; i < names.size(); i++) {
126+
var name = String(names.get(i));
127+
map[name] = resp.getHeader(name);
128+
}
129+
return map;
130+
},
131+
132+
_forceRefresh: function(options) {
133+
// Nudge cache by setting expiry in the past
134+
var expiryKey = options.propPrefix + '.expires_at';
135+
gs.setProperty(expiryKey, '0');
136+
return options;
137+
},
138+
139+
_assert: function(keys, obj) {
140+
keys.forEach(function(k) {
141+
if (!obj || typeof obj[k] === 'undefined' || obj[k] === null || obj[k] === '')
142+
throw 'Missing option: ' + k;
143+
});
144+
},
145+
146+
type: 'OAuthClientCredsHelper'
147+
};

0 commit comments

Comments
 (0)