Skip to content

Commit d7cea95

Browse files
authored
Integrations - OAuth2 client credentials token cache with auto-refresh - Pull 1 (#2374)
* Add README for OAuth 2.0 token cache with auto-refresh This README provides an overview of an OAuth 2.0 client-credentials token cache with auto-refresh functionality, detailing its purpose, usage, security considerations, options, and references. * 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. * Add background script for OAuth token handling example This script demonstrates how to use the OAuthClientCredsHelper for automatic OAuth bearer token handling in API calls.
1 parent 7441ac6 commit d7cea95

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-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+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# OAuth 2.0 client-credentials token cache with auto-refresh
2+
3+
## What this solves
4+
When integrating with external APIs, teams often re-implement the OAuth 2.0 client-credentials flow and forget to cache tokens or handle 401 refreshes. This helper:
5+
- Requests an access token from your token endpoint
6+
- Caches the token in a system property with an expiry timestamp
7+
- Adds the Bearer token to RESTMessageV2 requests
8+
- If the call returns 401 (expired token), refreshes once and retries
9+
10+
## Where to use
11+
Script Include in global or scoped apps. Call from Business Rules, Scheduled Jobs, Flow Actions, or Background Scripts.
12+
13+
## How it works
14+
- `getToken(options)` fetches or retrieves a cached token; stores `access_token` and `expires_at` (epoch ms) in system properties.
15+
- `request(options)` executes a resource call with Authorization header; on HTTP 401 it refreshes the token and retries once.
16+
- Token expiry has a 60-second buffer to avoid race on near-expiry tokens.
17+
18+
## Security notes
19+
- For production, store `client_secret` in a secure location (Credentials table or encrypted system property) and **do not** hardcode secrets in scripts.
20+
- This snippet reads/writes system properties under a chosen prefix. Ensure only admins can read/write them.
21+
22+
## Options
23+
For `getToken` and `request`:
24+
- `tokenUrl`: OAuth token endpoint URL
25+
- `clientId`: OAuth client id
26+
- `clientSecret`: OAuth client secret
27+
- `scope`: optional scope string
28+
- `audience`: optional audience parameter (some providers require it)
29+
- `propPrefix`: system property prefix for cache (e.g. `x_acme.oauth.sample`)
30+
- `resource` (request only): target API URL
31+
- `method` (request only): GET/POST/etc (default GET)
32+
- `headers` (request only): object of extra headers
33+
- `body` (request only): request body for POST/PUT/PATCH
34+
35+
## References
36+
- RESTMessageV2 API
37+
https://www.servicenow.com/docs/bundle/zurich-api-reference/page/app-store/dev_portal/API_reference/RESTMessageV2/concept/c_RESTMessageV2API.html
38+
- Direct RESTMessageV2 example
39+
https://www.servicenow.com/docs/bundle/zurich-api-reference/page/app-store/dev_portal/API_reference/RESTMessageV2/reference/r_DirectRESTMessageV2Example.html
40+
- OAuth 2.0 profiles in ServiceNow (concept)
41+
https://www.servicenow.com/docs/bundle/zurich-integrate-applications/page/integrate/outbound-rest/concept/c_oauth2-authentication.html
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Background Script example: call an API with automatic OAuth bearer handling
2+
(function() {
3+
var helper = new OAuthClientCredsHelper();
4+
5+
var options = {
6+
// Token settings
7+
tokenUrl: 'https://auth.example.com/oauth2/token',
8+
clientId: 'YOUR_CLIENT_ID',
9+
clientSecret: 'YOUR_CLIENT_SECRET', // store securely in real environments
10+
scope: 'read:things', // optional
11+
audience: '', // optional (for some IdPs)
12+
propPrefix: 'x_acme.oauth.sample', // system property prefix for cache
13+
14+
// Resource request
15+
resource: 'https://api.example.com/v1/things?limit=25',
16+
method: 'GET',
17+
headers: { 'Accept': 'application/json' }
18+
};
19+
20+
var res = helper.request(options);
21+
gs.info('Status: ' + res.status + ', refreshed=' + res.refreshed);
22+
gs.info('Body: ' + res.body);
23+
})();

0 commit comments

Comments
 (0)