Supercharge Your Suitelet Security with Google Sign In
Do you leverage NetSuite Suitelets to streamline external processes but worry about unauthorized access? This blog post dives into how Google Sign In can be your secret weapon for securing those Suitelets!
Why Google Sign In?
Imagine a world where only authorized users can access your Suitelets, preventing unauthorized modifications or data breaches. Google Sign In offers a seamless solution:
- Enhanced Security: Leverage Google’s robust authentication infrastructure to verify user identities. Say goodbye to managing separate login credentials for your Suitelets.
- Streamlined User Experience: Users can access Suitelets with their existing Google accounts, eliminating the need for additional logins and passwords.
- Simplified Management: No more managing user accounts for external Suitelet access. Google handles authentication, reducing your administrative burden.
Use Cases Galore!
Here are some compelling scenarios where Google Sign In for Suitelets shines:
- Vendor Portals: Grant secure access to vendors for order processing or data exchange, all through their Google accounts.
- Customer Self-Service: Empower customers to interact with secure Suitelets for tasks like order tracking or account management, using their Google logins.
- Partner Integrations: Securely connect with partner applications through Suitelets, leveraging Google authentication for seamless data exchange.
The Technical Rundown (Simplified!)
This Suitelet code utilizes Google Sign In’s OAuth 2.0 flow:
- User Initiates Login: The user attempts to access your Suitelet.
- Google Sign In Prompt: The Suitelet redirects the user to Google’s secure sign-in page.
- User Grants Access: If the user authorizes access, Google returns an authorization code.
- Authorization Code Exchange: The Suitelet exchanges the code for an access token from Google.
- User Verification: The Suitelet verifies the user’s email address using the access token, ensuring they are authorized. If they are not authorized then an error message is displayed and they are redirected back to start.
- Session Creation: A secure session is created for the authorized user, granting access to the Suitelet’s functionalities.
- Signout Process: The
signout
query parameter facilitates a secure logout. When accessed with?signout=T
, it not only clears the session cookie in the browser and redirects the user but also removes the session from the server-side cache, ensuring a comprehensive and secure exit from the system.
Session Authentication:
The Suitelet relies on session IDs for user authentication after successful Google Sign-In. This approach offers convenience but is important to understand its limitations:
- Session ID Matching: By default, the Suitelet validates a user based solely on a matching session ID stored in a cookie. This offers a relatively lenient approach to authentication.
- Enhanced Security (Optional): While the session ID provides basic authentication, the
session
object itself might also contain the user’s IP address. You can leverage this information to implement stricter authentication checks. For instance, you could validate that the user’s IP address during a request matches the IP address stored in the session object retrieved from the cache. This adds an extra layer of security, ensuring that the user accessing the Suitelet is not only authorized but also originates from the expected location.
Getting Started with Google Sign In
To enable Google Sign In for your Suitelets, you’ll need a Google Cloud Platform project and a Google API Client ID and Secret. Here’s a video on how to do it and here’s a quick guide to get you started:
- Create a Google Cloud Platform Project: Visit https://cloud.google.com/ and follow the instructions to create a new project.
- Configure OAuth Consent Screen: Navigate to the “OAuth consent screen” tab. Here, you’ll need to provide basic information about your application, such as an application name and logo.
- Application home page: Enter the exact URL of your external Suitelet.
- Authorized domain: netsuite.com
- Enable Google Sign-In API: Within your project, navigate to the “APIs & Services” section and enable the “Google Sign-In API”.
- Create Credentials: In the “Credentials” section, create credentials of type “OAuth client ID”. Choose “Web application” as the application type and provide a name for your Suitelet with the following info and click “Create”.
- Authorized redirect URIs: Enter the exact URL of your external Suitelet.
- Authorized JavaScript Origins: You can leave this empty.
- Copy Client ID and Secret: Once created, copy your Client ID and Secret to the CONFIG variable. These are crucial for configuring your Suitelet.
- Client ID: Looks like xxxxxxxxxxxx.apps.googleusercontent.com
- Client Secret: Looks like xxxxx-yyyy-zzzzzzzzzzzzzzzzz
Understanding the CONFIG Variable
The CONFIG
variable in the code snippet stores sensitive information used by the Suitelet, ideally you’ll want to move this to script parameters:
authorizedUsers
: This array lists authorized email addresses (lowercase) that can access the Suitelet after successful Google Sign In. You can populate this based on a saved search if desired.googleClientSecret
: This value is your downloaded Google Client Secret obtained in the previous steps. Keep it confidential!googleClientId
: This value is your downloaded Google Client ID obtained in the previous steps.scriptUrl
: This is the absolute URL of your Suitelet script itself. Avoid dynamically setting this based on scriptId/deploymentId. In my testing, I found that using url.resolveScript can sometimes include unexpected extra parameters, causing variations that can disrupt the authentication logic.sessionTtl
: This value defines the maximum session duration (in seconds) for authorized users. This is saved using N/cache so the duration is not guaranteed. The minimum is 300.
Conclusion
By integrating Google Sign In with your Suitelets, you gain a powerful layer of security and convenience. This approach streamlines user access, reduces administrative overhead, and leverages Google’s robust authentication infrastructure. The code provided is meant to help you get you started, there’s a world of possibilities and other services you can use to protect your Suitelets. So, why not give your Suitelets a security makeover with Google Sign In?
Remember:
- Keep your Google Client Secret confidential and store it securely.
- Enhance security by removing sensitive data from logs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
var CONFIG = { authorizedUsers : ['xxxxx@example.com', 'yyyyy@example.com'], //lowercase googleClientSecret : '<<GOOGLE_CLIENT_SECRET>>', googleClientId : '<<GOOGLE_CLIENT_ID>>', scriptUrl : '<<EXTERNAL_SUITELET_URL>>', sessionTtl : 86400 //maximum session time in seconds, 300 minimum } /** * @NApiVersion 2.1 * @NScriptType Suitelet * @NModuleScope Public */ define(['N/ui/serverWidget', 'N/cache', 'N/redirect', 'N/https', 'N/crypto/random'], runSuitelet); function runSuitelet(ui, cache, redirect, https, random) { function execute(context) { if(!validateAccessControlWithGoogle(context)){ return; } //##### AUTHORIZED VALID SESSION AFTER HERE ###### if (context.request.method === 'GET') { context.response.write(` <h1>GET. You are logged in.</h1> <a href="javascript:void%20function(){location.href+=%22%26signout=T%22}();">Sign Out</a> `); } else { context.response.write(`POST. You are logged in.`); } return; //###### HELPERS ###### /** * Validates access control using Google authentication and manages user sessions. Developed by Adolfo Garza at ursuscode.com * @param {Object} context - The context of the request containing parameters and session info. * @returns {boolean} - Returns true if the rest of the program should continue to run, false otherwise. */ function validateAccessControlWithGoogle(context){ const isSignOut = context.request.parameters.signout == 'T'; if(isSignOut){ log.debug('validateAccessControlWithGoogle|Signing out...'); const sessionIdFromCookie = extractCookieValue(context.request.headers["Cookie"], 'session_id'); clearServerSideSession(sessionIdFromCookie); deleteSessionCookie(context); redirectToHome(context); return false; } const validSession = isValidSession(context); log.debug('execute|validSession', validSession); if (!validSession) { const authCode = context.request.parameters.code; if (authCode) { handleAuthCodeFlow(context, authCode); return false; } redirectToGoogleOAuthServer(context); return false; } return true; /** * Checks if the current session is valid based on session ID from cookies. * @param {Object} context - The context object containing the request information. * @returns {boolean} - Returns true if the session is valid, false otherwise. */ function isValidSession(context) { const clientIp = context.request.clientIpAddress; const sessionIdFromCookie = extractCookieValue(context.request.headers["Cookie"], 'session_id'); return validateSession(sessionIdFromCookie); /** * Validates a session ID by checking the stored session data in the cache. * @param {string} sessionId - The session ID to validate. * @returns {boolean} - Returns true if the session ID is valid and the user is authorized, false otherwise. */ function validateSession(sessionId) { log.debug('validateSession|sessionId', sessionId); if(!sessionId){ return false; } const cacheName = sessionId; const sessionCache = cache.getCache({ name: cacheName, scope: cache.Scope.PRIVATE }); const sessionData = JSON.parse(sessionCache.get({ key: 'sessionData' }) || '{}'); log.debug('validateSession|sessionData', JSON.stringify(sessionData)); return isAuthorizedUser(sessionData.emailAddress); } } /** * Clears the server-side session from the cache using the session ID extracted from the cookie. * @param {Object} context - The context object from the Suitelet. */ function clearServerSideSession(sessionId) { if (sessionId) { log.debug('clearServerSideSession|sessionId', sessionId); const cacheName = sessionId; const sessionCache = cache.getCache({ name: cacheName, scope: cache.Scope.PRIVATE }); sessionCache.remove({ key: 'sessionData' }); } } /** * Extracts a specific cookie value from the cookie string. * @param {string} cookieString - The string containing all cookies. * @param {string} key - The key of the cookie to extract. * @returns {string|null} - Returns the value of the specified cookie, or null if not found. */ function extractCookieValue(cookieString, key) { return (cookieString || "").split(';').map(cookie => cookie.split('=')).find(parts => parts[0].trim() === key)?.[1] || null; } /** * Handles the authorization code flow for OAuth by exchanging the code for a token and validating the user. * @param {Object} context - The request context. * @param {string} authCode - The authorization code received from Google. * @returns {boolean} - Returns true if the authentication and session setup are successful, false otherwise. */ function handleAuthCodeFlow(context, authCode) { const clientIp = context.request.clientIpAddress; log.debug('handleAuthCodeFlow|authCode', authCode); const accessToken = fetchAccessToken(authCode); log.debug('handleAuthCodeFlow|accessToken', accessToken); if (!accessToken) { redirectToGoogleOAuthServer(context); return false; } const emailAddress = fetchEmailAddress(accessToken); if (!isAuthorizedUser(emailAddress)) { log.error('handleAuthCodeFlow|isAuthorizedUser', false); context.response.write(` <!DOCTYPE html> <html> <head> <title>Unauthorized Access</title> <meta http-equiv="refresh" content="10;url=${CONFIG.scriptUrl}"> </head> <body> <h1>Unauthorized User</h1> <p>You are not authorized to access this page. Redirecting you in 10 seconds...</p> </body> </html> `); return false; } const sessionId = random.generateUUID(); createUserSession(clientIp, accessToken, sessionId, emailAddress); setSessionCookie(context, sessionId); redirectToHome(context); return true; /** * Fetches an access token from Google's OAuth server using an authorization code. * @param {string} authCode - The authorization code provided by Google after user consent. * @returns {string|null} - Returns the access token if successfully retrieved, null otherwise. */ function fetchAccessToken(authCode) { log.debug('fetchAccessToken|authCode', authCode); const tokenResponse = https.post({ url: 'https://oauth2.googleapis.com/token', body: { code: authCode, client_id: CONFIG.googleClientId, client_secret: CONFIG.googleClientSecret, redirect_uri: CONFIG.scriptUrl, grant_type: 'authorization_code' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); const tokenData = JSON.parse(tokenResponse.body); if(tokenData.error || !tokenData.access_token){ log.error('fetchAccessToken|tokenResponse', tokenResponse.body); return; } return tokenData.access_token; } /** * Retrieves the user's email address from Google using the access token. * @param {string} accessToken - The access token to authenticate the request. * @returns {string|null} - Returns the user's email address if successfully retrieved, null otherwise. */ function fetchEmailAddress(accessToken) { const userInfoResponse = https.get({ url: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json', headers: { 'Authorization': 'Bearer ' + accessToken } }); if (userInfoResponse.code === 200) { return JSON.parse(userInfoResponse.body).email; } else { log.error('Failed to retrieve email', userInfoResponse.body); return null; } } /** * Creates a user session and stores it in the cache. * @param {string} clientIp - The IP address of the client initiating the session. * @param {string} accessToken - The access token of the user. * @param {string} sessionId - The session ID generated for the new session. * @param {string} emailAddress - The email address of the user. */ function createUserSession(clientIp, accessToken, sessionId, emailAddress) { log.debug('createUserSession|emailAddress', emailAddress); const sessionCache = cache.getCache({ name: sessionId, scope: cache.Scope.PRIVATE }); sessionCache.put({ key: 'sessionData', value: JSON.stringify({ ipAddress: clientIp, token: accessToken, sessionId: sessionId, emailAddress: emailAddress }), ttl: CONFIG.sessionTtl }); } /** * Sets a session cookie in the client's browser. * @param {Object} context - The context object from the Suitelet. * @param {string} sessionId - The session ID to set in the cookie. */ function setSessionCookie(context, sessionId) { log.debug('setSessionCookie|sessionId', sessionId); const expirationDate = new Date(Date.now() + CONFIG.sessionTtl * 1000); context.response.setHeader({ name: 'Set-Cookie', value: `session_id=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Strict; expires=${expirationDate.toUTCString()}`, }); } } /** * Checks if the given email address belongs to an authorized user. * @param {string} emailAddress - The email address to check. * @returns {boolean} - Returns true if the user is authorized, false otherwise. */ function isAuthorizedUser(emailAddress) { return CONFIG.authorizedUsers.includes((emailAddress || "").toLowerCase()); } /** * Redirects the user to a "home" URL, wipes the url params clean. * @param {Object} context - The context of the Suitelet script. */ function redirectToHome(context){ log.debug('redirectToHome|Cleaning URL params...'); //Redirect to clean the url params let htmlBody = `<!DOCTYPE HTML> <html lang="en-US"> <head> <meta charset="UTF-8"> <meta http-equiv="refresh" content="0; url=${CONFIG.scriptUrl}"> <script type="text/javascript"> window.location.href = "${CONFIG.scriptUrl}" </script> <title>Success</title> </head> <body> If you are not redirected automatically, follow this <a href='${CONFIG.scriptUrl}'>link</a>. </body> </html>` context.response.write(htmlBody); return; } /** * Deletes the session cookie by setting its expiration date to a past value. * @param {Object} context - The context of the Suitelet script. */ function deleteSessionCookie(context) { log.debug('deleteSessionCookie'); context.response.setHeader({ name: 'Set-Cookie', value: `session_id=; Path=/; HttpOnly; Secure; SameSite=Strict; expires=Sun, 1 Jan 2023 00:00:00 UTC;`, }); } /** * Redirects the user to the Google OAuth server to initiate the authentication flow. * @param {Object} context - The context of the Suitelet script. */ function redirectToGoogleOAuthServer(context) { log.debug('redirectToGoogleOAuthServer|Redirecting...'); const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + 'prompt=select_account' + '&response_type=code' + '&client_id=' + encodeURIComponent(CONFIG.googleClientId) + '&redirect_uri=' + encodeURIComponent(CONFIG.scriptUrl) + '&scope=' + encodeURIComponent('https://www.googleapis.com/auth/userinfo.email'); redirect.redirect({ url: authUrl }); } } } return { onRequest: execute }; } |