From ea8c16905a117a0e11d1ddaf481ad03a1654abbd Mon Sep 17 00:00:00 2001 From: hanna-g-sn Date: Tue, 21 Oct 2025 11:58:18 +0100 Subject: [PATCH 1/3] 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. --- .../README.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/README.md diff --git a/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/README.md b/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/README.md new file mode 100644 index 0000000000..4022c4006e --- /dev/null +++ b/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/README.md @@ -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 From e669bb295987614617c776e48c1a7d52131cf701 Mon Sep 17 00:00:00 2001 From: hanna-g-sn Date: Tue, 21 Oct 2025 11:59:05 +0100 Subject: [PATCH 2/3] 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. --- .../OAuthClientCredsHelper.js | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/OAuthClientCredsHelper.js diff --git a/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/OAuthClientCredsHelper.js b/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/OAuthClientCredsHelper.js new file mode 100644 index 0000000000..a47c962c08 --- /dev/null +++ b/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/OAuthClientCredsHelper.js @@ -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' +}; From 43291d9db24fd58e44a3b4ba3382dcbb664df36e Mon Sep 17 00:00:00 2001 From: hanna-g-sn Date: Tue, 21 Oct 2025 11:59:48 +0100 Subject: [PATCH 3/3] 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. --- .../example_background_usage.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/example_background_usage.js diff --git a/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/example_background_usage.js b/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/example_background_usage.js new file mode 100644 index 0000000000..3a942346dd --- /dev/null +++ b/Integration/RESTMessageV2/Auth2 client credentials token cache with auto-refresh/example_background_usage.js @@ -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); +})();