Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Script Include: OAuthClientCredsHelper
* Purpose: Perform OAuth 2.0 client-credentials token acquisition and caching,
* and wrap RESTMessageV2 calls with automatic token injection and refresh.
*
* SECURITY: Store clientSecret in a secure location (Credentials or encrypted property).
*/
var OAuthClientCredsHelper = Class.create();
OAuthClientCredsHelper.prototype = {
initialize: function() {},

/**
* Execute an API request with Bearer token and one-shot auto-refresh on 401.
* Returns an object {status, body, headers, refreshed:Boolean}
*/
request: function(options) {
var token = this.getToken(options); // may fetch or use cached

var res = this._call(options, token);
if (res.status !== 401) return res;

// If 401, refresh token once and retry
var refreshed = this.getToken(this._forceRefresh(options));
var retry = this._call(options, refreshed);
retry.refreshed = true;
return retry;
},

/**
* Get a cached token or fetch a new one if expired/near-expiry.
* Returns the access_token string.
*/
getToken: function(options) {
this._assert(['tokenUrl', 'clientId', 'clientSecret', 'propPrefix'], options);
var now = new Date().getTime();

var tokenKey = options.propPrefix + '.access_token';
var expiryKey = options.propPrefix + '.expires_at';

var cached = gs.getProperty(tokenKey, '');
var expiresAt = parseInt(gs.getProperty(expiryKey, '0'), 10) || 0;

// 60-second safety buffer
var bufferMs = 60 * 1000;
if (cached && expiresAt > (now + bufferMs)) {
return cached;
}

// Need a fresh token
var fresh = this._fetchToken(options);
gs.setProperty(tokenKey, fresh.access_token);
gs.setProperty(expiryKey, String(fresh.expires_at));
return fresh.access_token;
},

// ------------------ internals ------------------

_call: function(options, accessToken) {
var r = new sn_ws.RESTMessageV2();
r.setEndpoint(options.resource);
r.setHttpMethod((options.method || 'GET').toUpperCase());
r.setRequestHeader('Authorization', 'Bearer ' + accessToken);
// Extra headers
Object.keys(options.headers || {}).forEach(function(k) {
r.setRequestHeader(k, options.headers[k]);
});

if (options.body && /^(POST|PUT|PATCH)$/i.test(options.method || 'GET')) {
r.setRequestBody(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
// set content type if caller didn't
if (!options.headers || !options.headers['Content-Type']) {
r.setRequestHeader('Content-Type', 'application/json');
}
}

var resp = r.execute();
return {
status: resp.getStatusCode(),
body: resp.getBody(),
headers: this._collectHeaders(resp),
refreshed: false
};
},

_fetchToken: function(options) {
// RFC 6749 client-credentials: POST x-www-form-urlencoded
var r = new sn_ws.RESTMessageV2();
r.setEndpoint(options.tokenUrl);
r.setHttpMethod('POST');
r.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');

var params = [
'grant_type=client_credentials',
'client_id=' + encodeURIComponent(options.clientId),
'client_secret=' + encodeURIComponent(options.clientSecret)
];
if (options.scope) params.push('scope=' + encodeURIComponent(options.scope));
if (options.audience) params.push('audience=' + encodeURIComponent(options.audience));

r.setRequestBody(params.join('&'));

var resp = r.execute();
var status = resp.getStatusCode();
var body = resp.getBody();
if (status < 200 || status >= 300) {
throw 'Token endpoint HTTP ' + status + ': ' + body;
}

var json;
try { json = JSON.parse(body); }
catch (e) { throw 'Invalid token JSON: ' + e.message; }

var access = json.access_token;
var ttlSec = Number(json.expires_in || 3600);
if (!access) throw 'Token response missing access_token';

var now = new Date().getTime();
var expiresAt = now + (ttlSec * 1000);
return { access_token: access, expires_at: expiresAt };
},

_collectHeaders: function(resp) {
var map = {};
var names = resp.getAllHeaders();
for (var i = 0; i < names.size(); i++) {
var name = String(names.get(i));
map[name] = resp.getHeader(name);
}
return map;
},

_forceRefresh: function(options) {
// Nudge cache by setting expiry in the past
var expiryKey = options.propPrefix + '.expires_at';
gs.setProperty(expiryKey, '0');
return options;
},

_assert: function(keys, obj) {
keys.forEach(function(k) {
if (!obj || typeof obj[k] === 'undefined' || obj[k] === null || obj[k] === '')
throw 'Missing option: ' + k;
});
},

type: 'OAuthClientCredsHelper'
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# OAuth 2.0 client-credentials token cache with auto-refresh

## What this solves
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:
- Requests an access token from your token endpoint
- Caches the token in a system property with an expiry timestamp
- Adds the Bearer token to RESTMessageV2 requests
- If the call returns 401 (expired token), refreshes once and retries

## Where to use
Script Include in global or scoped apps. Call from Business Rules, Scheduled Jobs, Flow Actions, or Background Scripts.

## How it works
- `getToken(options)` fetches or retrieves a cached token; stores `access_token` and `expires_at` (epoch ms) in system properties.
- `request(options)` executes a resource call with Authorization header; on HTTP 401 it refreshes the token and retries once.
- Token expiry has a 60-second buffer to avoid race on near-expiry tokens.

## Security notes
- For production, store `client_secret` in a secure location (Credentials table or encrypted system property) and **do not** hardcode secrets in scripts.
- This snippet reads/writes system properties under a chosen prefix. Ensure only admins can read/write them.

## Options
For `getToken` and `request`:
- `tokenUrl`: OAuth token endpoint URL
- `clientId`: OAuth client id
- `clientSecret`: OAuth client secret
- `scope`: optional scope string
- `audience`: optional audience parameter (some providers require it)
- `propPrefix`: system property prefix for cache (e.g. `x_acme.oauth.sample`)
- `resource` (request only): target API URL
- `method` (request only): GET/POST/etc (default GET)
- `headers` (request only): object of extra headers
- `body` (request only): request body for POST/PUT/PATCH

## References
- RESTMessageV2 API
https://www.servicenow.com/docs/bundle/zurich-api-reference/page/app-store/dev_portal/API_reference/RESTMessageV2/concept/c_RESTMessageV2API.html
- Direct RESTMessageV2 example
https://www.servicenow.com/docs/bundle/zurich-api-reference/page/app-store/dev_portal/API_reference/RESTMessageV2/reference/r_DirectRESTMessageV2Example.html
- OAuth 2.0 profiles in ServiceNow (concept)
https://www.servicenow.com/docs/bundle/zurich-integrate-applications/page/integrate/outbound-rest/concept/c_oauth2-authentication.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Background Script example: call an API with automatic OAuth bearer handling
(function() {
var helper = new OAuthClientCredsHelper();

var options = {
// Token settings
tokenUrl: 'https://auth.example.com/oauth2/token',
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET', // store securely in real environments
scope: 'read:things', // optional
audience: '', // optional (for some IdPs)
propPrefix: 'x_acme.oauth.sample', // system property prefix for cache

// Resource request
resource: 'https://api.example.com/v1/things?limit=25',
method: 'GET',
headers: { 'Accept': 'application/json' }
};

var res = helper.request(options);
gs.info('Status: ' + res.status + ', refreshed=' + res.refreshed);
gs.info('Body: ' + res.body);
})();
Loading