We use OAuth 2.0 to secure access to a user's Blackbaud data. In this tutorial we obtain user authorization using the Authorization Code Flow. From the user's perspective, the user authenticates using their Blackbaud ID credentials and then authorizes (or denies) your application. To accomplish this, your application obtains an authorization code from the Blackbaud OAuth 2.0 Service. The authorization code is then exchanged for an access token that signs requests to the SKY API on behalf of the user. The exchange involves your registered application's Application secret. For security reasons, the exchange is done through direct server-to-server communication. For this reason, we use Node.js, a server-side platform.
In this tutorial, we will accomplish the following tasks:
For this tutorial, we strip down the user interface to highlight the Authorization Code Flow. Our Barkbaud code sample provides a rich user interface using SKY UX.
If you have not already done so, get a subscription to an API product. Your subscription contains a Primary key and a Secondary key. You can use either key as the subscription key value for the Bb-Api-Subscription-Key
After you register a SKY application, you'll find its ID and secret displayed at the top of the page. These credentials are unique to your application, and will be used to verify its identity during the authorization process.
After you have your subscription key, Application ID, and Application secret, it's time to establish your development environment. Since we are using the Authorization Code Flow, we need to use a server-side software platform. For this tutorial, we will use Node.js.
testserver.js
// Create a very simple HTTP web server on your local machine. // Set up a HTTP Web server and client, require('http'). var http = require('http'); // createServer returns a new instance of the http server. // A function is used as a request listener. // req is an instance of the incoming request. // res is an instance of the server response. // When you browse to http://localhost:1337/, a 'request' event occurs and // "Hello World" is written from the HTTP Web server back to your browser. http.createServer(function (req, res) { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); }).listen(1337, "localhost"); console.log('Server running at http://localhost:1337/');
// Create a very simple HTTP web server on your local machine.
// Set up a HTTP Web server and client, require('http').
var http = require('http');
// createServer returns a new instance of the http server.
// A function is used as a request listener.
// req is an instance of the incoming request.
// res is an instance of the server response.
// When you browse to http://localhost:1337/, a 'request' event occurs and
// "Hello World" is written from the HTTP Web server back to your browser.
http.createServer(function (req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World');
}).listen(1337, "localhost");
console.log('Server running at http://localhost:1337/');
testnodejs
testnodejs
testserver.js
$ cd testnodejs $ node testserver.js
$ cd testnodejs
$ node testserver.js
CTRL-C
The sky-api-auth-tutorial repo on GitHub provides a starter project to work through the Authorization Code Flow.
Use a command prompt to clone the sky-api-auth-tutorial
sky-api-auth-tutorial
$ git clone https://github.com/blackbaud/sky-api-auth-tutorial.git
$ git clone https://github.com/blackbaud/sky-api-auth-tutorial.git
sky-api-auth-tutorial
sky.env-sample
sky.env
sky.env
sky.env
AUTH_CLIENT_ID | Your registered application's Application ID (from Step 2). See Managing your apps. |
AUTH_CLIENT_SECRET | Your registered application's Application Secret (from Step 2). See Managing your apps. |
AUTH_REDIRECT_URI | One of your registered application's Redirect URIs (from Step 2). For this tutorial, enter http://localhost:5000/auth/callback |
AUTH_SUBSCRIPTION_KEY | Your Blackbaud Developer Subscription Key. Use either the Primary key or Secondary key visible on your Blackbaud Developer Profile. |
PORT | The Web server port that will run the application. For this tutorial, enter 5000 |
.gitignore
.env
From the working directory, run npm install
NPM is the package manager that comes bundled with Node.js. Since you already installed Node.js, you also have NPM. The command npm install
package.json
node_modules
node_modules
$ cd sky-api-auth-tutorial $ npm install
$ cd sky-api-auth-tutorial
$ npm install
npm install
sky-api-auth-tutorial
node_modules
npm install
depends on a reliable Internet connection to install dependencies. If you have issues running the command, you can hard delete the node_modules
npm install
npm start
$ npm start
$ npm start
index.js
https://localhost:5000
ui
index.html
body
app
body
app
<body> <div id="app"> </div> ... <body>
<body>
<div id="app">
</div>
...
<body>
app
main.js
HTML
document.addEventListener("DOMContentLoaded", function () )
document.addEventListener("DOMContentLoaded", function () )
initApp
function initApp() { // Check if the user is authenticated fetch('/auth/authenticated') .then(response => response.json()) .then(data => { if (data.authenticated) { // User is authenticated, fetch constituent data fetch('/api/constituents/280') .then(response => response.json()) .then(data => { const constituent = data; renderConstituentData(constituent); }); } else { renderLoginOptions(); } }); }
function initApp() {
// Check if the user is authenticated
fetch('/auth/authenticated')
.then(response => response.json())
.then(data => {
if (data.authenticated) {
// User is authenticated, fetch constituent data
fetch('/api/constituents/280')
.then(response => response.json())
.then(data => {
const constituent = data;
renderConstituentData(constituent);
});
} else {
renderLoginOptions();
}
});
}
main.js
title
<a href="/auth/login" class="btn btn-primary btn-block btn-lg"><i class="fa fa-external-link"></i> Authorize using redirect</a>
<a href="/auth/login" class="btn btn-primary btn-block btn-lg"><i class="fa fa-external-link"></i> Authorize using redirect</a>
The Authorize using popup button opens a popup window that is directed to the server's authorization endpoint to initiate the authentication process.
JavaScript adds a click handler to the button. When clicked, the handler checks whether we've already launched the authorization popup window previously. If not, we open the new authorization popup window and set up a timer with setInterval
document.getElementById("popup-login").addEventListener("click", function () { if (popup && !popup.closed) { popup.focus(); return; } // Open a new popup window for login popup = window.open('auth/login?redirect=/%23/auth-success', 'login', 'height=450,width=600'); // Focus the popup window if possible if (window.focus) { popup.focus(); } // Set an interval to check every 500ms if the user has authenticated intervalId = setInterval(function () { // Check if the popup window is still open if (popup && popup.closed) { // If the popup is closed, clear the interval and reset variables clearInterval(intervalId); intervalId = null; popup = null; return; } // Check if the user is authenticated by making a // request to /auth/authenticated fetch('/auth/authenticated') .then(response => response.json()) .then(data => { // If the user is authenticated, clear the interval and //close the popup if (data.authenticated) { clearInterval(intervalId); intervalId = null; popup.close(); popup = null; // Fetch constituent data now that the user is authenticated fetch('/api/constituents/280') .then(response => response.json()) .then(data => { const constituent = data; // Render the constituent data renderConstituentData(constituent); }); } }); }, 500); });
document.getElementById("popup-login").addEventListener("click", function () {
if (popup && !popup.closed) {
popup.focus();
return;
}
// Open a new popup window for login
popup = window.open('auth/login?redirect=/%23/auth-success', 'login', 'height=450,width=600');
// Focus the popup window if possible
if (window.focus) {
popup.focus();
}
// Set an interval to check every 500ms if the user has authenticated
intervalId = setInterval(function () {
// Check if the popup window is still open
if (popup && popup.closed) {
// If the popup is closed, clear the interval and reset variables
clearInterval(intervalId);
intervalId = null;
popup = null;
return;
}
// Check if the user is authenticated by making a
// request to /auth/authenticated
fetch('/auth/authenticated')
.then(response => response.json())
.then(data => {
// If the user is authenticated, clear the interval and
//close the popup
if (data.authenticated) {
clearInterval(intervalId);
intervalId = null;
popup.close();
popup = null;
// Fetch constituent data now that the user is authenticated
fetch('/api/constituents/280')
.then(response => response.json())
.then(data => {
const constituent = data;
// Render the constituent data
renderConstituentData(constituent);
});
}
});
}, 500);
});
Open index.js
/server/routes/auth.js
The Authorize button prompt a request to the web server's /auth/login
index.js
getLogin()
/server/routes/auth.js
// Register our OAUTH2 routes app.get('/auth/authenticated', routes.auth.getAuthenticated); app.get('/auth/login', routes.auth.getLogin); app.get('/auth/callback', routes.auth.getCallback); app.get('/auth/logout', routes.auth.getLogout);
// Register our OAUTH2 routes
app.get('/auth/authenticated', routes.auth.getAuthenticated);
app.get('/auth/login', routes.auth.getLogin);
app.get('/auth/callback', routes.auth.getCallback);
app.get('/auth/logout', routes.auth.getLogout);
auth.js
sky.env
AUTH_CLIENT_ID
AUTH_CLIENT_SECRET
var { AuthorizationCode } = require('simple-oauth2'); config = { client: { id: process.env.AUTH_CLIENT_ID, secret: process.env.AUTH_CLIENT_SECRET }, auth: { tokenHost: 'https://oauth2.sky.blackbaud.com', authorizePath: '/authorization', tokenPath: '/token' } }; authCodeClient = new AuthorizationCode(config);
var { AuthorizationCode } = require('simple-oauth2');
config = {
client: {
id: process.env.AUTH_CLIENT_ID,
secret: process.env.AUTH_CLIENT_SECRET
},
auth: {
tokenHost: 'https://oauth2.sky.blackbaud.com',
authorizePath: '/authorization',
tokenPath: '/token'
}
};
authCodeClient = new AuthorizationCode(config);
getLogin()
authorizeURL()
AUTH_REDIRECT_URI
https://localhost:5000/auth/callback
function getLogin(request, response) { var codeVerifier, codeChallenge, challengeDigest; function base64URLEncode(str) { return str.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } request.session.redirect = request.query.redirect; request.session.state = crypto.randomBytes(48).toString('hex'); codeVerifier = base64URLEncode(crypto.randomBytes(32)); challengeDigest = crypto .createHash("sha256") .update(codeVerifier) .digest(); codeChallenge = base64URLEncode(challengeDigest); request.session.code_verifier = codeVerifier; response.redirect(authCodeClient.authorizeURL({ redirect_uri: process.env.AUTH_REDIRECT_URI, state: request.session.state, code_challenge: codeChallenge, code_challenge_method: 'S256' })); }
function getLogin(request, response) {
var codeVerifier,
codeChallenge,
challengeDigest;
function base64URLEncode(str) {
return str.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
request.session.redirect = request.query.redirect;
request.session.state = crypto.randomBytes(48).toString('hex');
codeVerifier = base64URLEncode(crypto.randomBytes(32));
challengeDigest = crypto
.createHash("sha256")
.update(codeVerifier)
.digest();
codeChallenge = base64URLEncode(challengeDigest);
request.session.code_verifier = codeVerifier;
response.redirect(authCodeClient.authorizeURL({
redirect_uri: process.env.AUTH_REDIRECT_URI,
state: request.session.state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}));
}
/auth/callback
getCallback()
async function getCallback(request, response) { ... options = { code: request.query.code, redirect_uri: process.env.AUTH_REDIRECT_URI, code_verifier: request.session.code_verifier }; try { accessToken = await authCodeClient.getToken(options); redirect = request.session.redirect || '/'; request.session.redirect = ''; request.session.state = ''; request.session.code_verifier = undefined; saveTicket(request, accessToken.token); response.redirect(redirect); } catch (errorToken) { error = errorToken.message; } ... }
async function getCallback(request, response) {
...
options = {
code: request.query.code,
redirect_uri: process.env.AUTH_REDIRECT_URI,
code_verifier: request.session.code_verifier
};
try {
accessToken = await authCodeClient.getToken(options);
redirect = request.session.redirect || '/';
request.session.redirect = '';
request.session.state = '';
request.session.code_verifier = undefined;
saveTicket(request, accessToken.token);
response.redirect(redirect);
} catch (errorToken) {
error = errorToken.message;
}
...
}
saveTicket()
function saveTicket(request, ticket) { request.session.ticket = ticket; request.session.expires = (new Date().getTime() + (1000 * ticket.expires_in)); }
function saveTicket(request, ticket) {
request.session.ticket = ticket;
request.session.expires = (new Date().getTime() + (1000 * ticket.expires_in));
}
The application initializes by checking if the user is authenticated through a call to the /auth/authenticated
/api/constituents/280
function initApp() { fetch('/auth/authenticated') .then(response => response.json()) .then(data => { if (!data.authenticated) { renderLoginOptions(); } else { fetch('/api/constituents/280') .then(response => response.json()) .then(data => { const constituent = data; renderConstituentData(constituent);); } }); }
function initApp() {
fetch('/auth/authenticated')
.then(response => response.json())
.then(data => {
if (!data.authenticated) {
renderLoginOptions();
} else {
fetch('/api/constituents/280')
.then(response => response.json())
.then(data => { const constituent = data;
renderConstituentData(constituent););
}
});
}
/server/libs/sky.js
/server/routes/api.js
index.js
getConstituent()
/server/libs/sky.js
/server/routes/api.js
proxy()
Bb-Api-Subscription-Key
Authorization
function proxy(request, method, endpoint, body, callback) { var options = { json: true, method: method, body: body, url: 'Blackbaud OAuth 2.0 Service/' + endpoint, headers: { 'bb-api-subscription-key': process.env.AUTH_SUBSCRIPTION_KEY, 'Authorization': 'Bearer ' + request.session.ticket.access_token } }; promise(options) .then(callback) .catch(function (err) { console.log('Proxy Error: ', err); }); }
function proxy(request, method, endpoint, body, callback) {
var options = {
json: true,
method: method,
body: body,
url: '/' + endpoint,
headers: {
'bb-api-subscription-key': process.env.AUTH_SUBSCRIPTION_KEY,
'Authorization': 'Bearer ' + request.session.ticket.access_token
}
};
promise(options)
.then(callback)
.catch(function (err) {
console.log('Proxy Error: ', err);
});
}
The Bb-Api-Subscription-Key
Authorization
Authorization
Bearer
A call to the Constituent (Get) endpoint retrieves constituent data and sends it back to the browser.
function get(request, endpoint, callback) { return proxy(request, 'GET', endpoint, '', callback); }
function get(request, endpoint, callback) {
return proxy(request, 'GET', endpoint, '', callback);
}
<div> <h1>SKY API Authorization Code Flow Tutorial</h1> <div class="well"> <h3 class="well-title">Constituent: {{ constituent.name }}</h3> <p> See <a href="https://developer.blackbaud.com/skyapi/products/renxt/constituent" target="_blank">Constituent</a> within the Blackbaud SKY API contact reference for a full listing of properties. </p> </div> <div class="table-responsive"> <table class="table table-striped table-hover"> <thead> <tr> <th>Key</th> <th>Value</th> </tr> </thead> <tbody> <tr> <td>id</td> <td>{{ constituent.id }}</td> </tr> <tr> <td>type</td> <td>{{ constituent.type }}</td> </tr> <tr> <td>lookup_id</td> <td>{{ constituent.lookup_id }}</td> </tr> <tr> <td>first</td> <td>{{ constituent.first }}</td> </tr> <tr> <td>last</td> <td>{{ constituent.last }}</td> </tr> </tbody> </table> </div> <a href="/auth/logout" class="btn btn-primary">Log out</a> </div>
<div>
<h1>SKY API Authorization Code Flow Tutorial</h1>
<div class="well">
<h3 class="well-title">Constituent: {{ constituent.name }}</h3>
<p>
See <a href="https://developer.blackbaud.com/skyapi/products/renxt/constituent" target="_blank">Constituent</a>
within the Blackbaud SKY API contact reference for a full listing of properties.
</p>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>id</td>
<td>{{ constituent.id }}</td>
</tr>
<tr>
<td>type</td>
<td>{{ constituent.type }}</td>
</tr>
<tr>
<td>lookup_id</td>
<td>{{ constituent.lookup_id }}</td>
</tr>
<tr>
<td>first</td>
<td>{{ constituent.first }}</td>
</tr>
<tr>
<td>last</td>
<td>{{ constituent.last }}</td>
</tr>
</tbody>
</table>
</div>
<a href="/auth/logout" class="btn btn-primary">Log out</a>
</div>
You should now have a fully functioning application. Users of your app should be able to log in with their Blackbaud credentials, authorize the application, and get constituent data after they are authenticated.
Be sure to take a look at our other code samples. You can create an issue to report a bug or request a feature for this code sample. For all other feature requests, see ideas.