Device Authentication
Using the client authentication flow to authorize a device and get user info.
One of the benefits of the Senza platform is that each cloud connector device has a unique identifier, so you can implement device-based authentication that is completely transparent to the user. They won't need to bother entering a username and password, because simply having a device gives them the access they need. And you don't need to be concerned with password sharing, because every device can be bound to a specific user.
- Source code: https://github.com/synamedia-senza/hello
- Demo: https://senzadev.net/hello/
- Video tutorial: Device Authentication
For this tutorial, we are assuming that all devices are bound to a user account before they are distributed. For a deployment strategy where users may need to sign in with a username and password to bind a device to their account, see part two: QR Code Authentication.
Authentication Flow
You may wish to familiarize yourself with the Authentication Flow first. At a very high level, the interaction between the client and the server works like this:
- The client gets a client assertion from the auth object and requests an auth token.
- The server validates the client assertion, decodes the device ID, and returns an auth token.
- The client uses the auth token to make API calls, in this case to request the user info.
- The server looks up the device ID using the auth token, and uses that to return the response.
This demo is a simplified use case in which a single server handles the authentication, stores the user info, and serves the app. In production these responsibilities may be distributed among several different backend services.
Running the app
First let's run the app, and then we will go over all the code that makes it work.
Start by cloning the repo to your computer.
In the project directory, first install the packages for the server:
npm install
We'll run the server locally and then use ngrok to load the app using Senza.
node server.js
ngrok http 8080
Take the URL that is displayed by ngrok and run the app in the simulator or on a device. You'll want to use the remote debugger to follow what is happening in the console.
Client and Server
We're going to examine the code in two files:
- public/hello.js is the client
- server.js is the server
We will alternate between the client and the server to follow the flow back and forth.
Client Main
The main body of the client looks like this. At a high level, we're going to:
- Get the client assertion.
- Get the access token from the server.
- Get the user info from the server using the access token.
- Use the user info to update the interface.
import { init, uiReady, auth, deviceManager } from "senza-sdk";
let accessToken = "";
window.addEventListener("load", async () => {
await init();
let assertion = await getClientAssertion();
accessToken = await getAccessToken(assertion);
console.log("Access token: " + accessToken);
hello();
uiReady();
});
Get Client Assertion
We're going to use the app authentication object and call the getClientAssertion()
method.
async function getClientAssertion() {
try {
const client_assertion = await auth.getClientAssertion();
console.log("Client assertion: ", client_assertion);
return client_assertion.hostplatform_assertion;
} catch (e) {
console.error("getClientAssertion failed", e);
}
}
That method will return a JSON object with a single hostplatform_assertion
value, which the function returns.
Get Access Token
We'll then use that assertion to make a request the server's /auth/token
endpoint for an access token. Rather than using a JSON body, the convention for this type of request is to use URL encoded form data. We'll specify the grant_type
and assertion
as follows:
async function getAccessToken(assertion) {
let response = await fetch("/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
body: "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=" + assertion
});
let json = await response.json();
return json.access_token;
}
This function will return the access token.
Authorize
In order to validate that the client assertion is legitimate, you can enter an auth audience value in the Device Authentication section of the tenant configuration. It can be any string that you like. This value will then be included in the client assertion as the aud
property. If you enter the same value in the app's config.json
file, then the app can validate that the audience is the one that is expected and the client assertion is legit.
Let's switch to the server to see how that endpoint is implemented. Here we:
- Check that the request includes an assertion.
- Decode the JWT to get the payload data.
- Validate that the audience value in the payload matches the one in the config file.
- Decode the JWKS (JSON Web Key Sets). The inner workings of the encryption are beyond the scope of this tutorial, but you can follow along in the console if you like looking at RSA keys.
- Generate an access token from the payload data and return it in the response.
let tokenDevices = {};
let users = config.users;
let devices = config.devices;
app.post("/auth/token", async (req, res) => {
const clientAssertion = req.body.assertion;
if (!clientAssertion) {
res.status(400).json({message: "Invalid Request"});
return;
}
try {
const payload = decodeJwt(clientAssertion);
console.log("JWT:", payload);
validateClientAssertion(payload);
const jwks = await getJwks(payload.iss);
const token = await generateAccessToken(payload);
res.status(200).json({"access_token":token, "token_type": "bearer", "expires_in": "XXX"});
} catch (err) {
console.error(`Error validate client assertion: : ${err}`);
res.status(401).json({message: "Error validating client assertion"});
}
});
function validateClientAssertion(payload) {
if (config.audience && config.audience.length && payload.aud != config.audience) {
throw new Error(`Invalid audience! Expected ${config.audience}, received ${payload.aud}`);
}
}
Both the client and the server include a significant amount of logging to you can inspect the data that flows back and forth. For example, a JWT payload may look like this:
JWT: {
iss: 'https://oauth-config.streaming.synamedia.com/authn',
aud: 'https://sg-goldenberry.vsscloud.tv:9443/oauth2/token',
iat: 1710890213,
jti: 'c42ce865-5635-4c16-b60a-324ccdaf52d7',
sub: 'urn:synamedia:oauth:identifier:hyperscale:7e6d37c30d21af04',
exp: 1710976613
}
The part we care about is the device ID, which is the last component in the subject string. Here's a handy function to extract it for us:
function getDeviceId(payload) {
let subjects = payload.sub.split(":");
return subjects[subjects.length - 1];
}
Next, we'll generate an access token. Here we:
- Parse the device ID from the payload.
- Create a new access token, which is just a bunch of random letters and numbers.
- Add an entry in our index that will let us look up the device ID for an access token.
async function generateAccessToken(payload) {
let deviceId = getDeviceId(payload);
let accessToken = newAcccessToken();
tokenDevices[accessToken] = deviceId;
console.log(`Set device ${deviceId} for access token ${accessToken}`);
return accessToken;
}
function newAcccessToken() {
return Array.from({ length: 6 }, () => Math.random().toString(36).substr(2)).join('');
};
Then we return the access token to the client.
Request User Info
Back in the client, once we have an access token we'll use it for making all authenticated requests to the server. This access token, which normally should be valid for a limited amount of time, asserts to the server that the client is authenticated (we know who they are) and authorized (they are allowed to make requests).
We'll use the access token to make a GET request to the /hello
endpoint, which returns information about the current user. The standard for making authenticated requests is to include an Authorization
header with a value that starts with the string Bearer
followed by the access token.
async function hello() {
let response = await fetch("/hello", {
headers: { "Authorization": "Bearer " + accessToken }
});
let json = await response.json();
console.log("Hello: ", json);
updateUserInfo(json);
}
Fetch User Info
Back in the server, when we receive an authenticated request to the /hello
endpoint, we:
- Validate that the access token maps to a device ID, otherwise return an error.
- Look up the user info using the device ID. If it's not found, we'll either return an error or return some default user info if our guest policy allows it.
app.get('/hello', function (req, res) {
try {
let deviceId = validateAccessToken(req);
let userInfo = getUserInfo(deviceId, true);
res.json(userInfo);
} catch (error) {
res.status(401).json({message: error.message});
}
});
We will encapsulate the logic for validating the access token in a separate function, which will throw an error if it is missing or invalid. You can try modifying the client code to omit the access token or send a random string to validate that these error messages are returned to the client with a 401 Unauthorized error.
function validateAccessToken(req) {
let authorization = req.headers.authorization;
if (!authorization || !authorization.startsWith("Bearer ")) {
throw new Error("No access token!");
}
let accessToken = authorization.substring("Bearer ".length);
let deviceId = tokenDevices[accessToken];
if (!deviceId) {
throw new Error("Invalid access token!");
}
console.log(`Got device ${deviceId} for access token ${accessToken}`);
return deviceId;
}
We have a separate function for getting user info from the user list. It's just a simple lookup, but for a good demo experience we have an allowGuest
flag that will fall back to using default user info if a device is not found. Otherwise it will throw an error.
function getUserInfo(deviceId) {
let username = devices[deviceId];
if (!username) throw new Error("Device not authorized!");
let userInfo = users[username];
if (!userInfo) throw new Error("Unknown user!");
console.log("User info:", userInfo);
return userInfo;
}
Our user database is a simple config.json file that has two objects:
users
is a user database indexed by username. Each record contains the user's name and favorite color.devices
is a mapping from device ID to username.
{
"users": {
"andrew": {
"name": "Andrew",
"color": "#FBD6D3",
},
"eliza": {
"name": "Eliza",
"color": "#CCCDFD",
},
"sergio": {
"name": "Sergio",
"color": "#E3D0E5",
}
},
"devices": {
"06fffbeb0d21af04": "andrew",
"06fffbd70d21af04": "eliza",
"06fffb270d21af04": "sergio"
},
"audience: ""
}
In a production, when you ship a device to a user you'll want to make note of the device ID and associate it with their username in the devices list.
For this demo, you can add records to the users and devices lists to personalize the experience for yourself.
Receive User Info
Back in the client, we'll use the user info to update the interface. We'll greet the user by name and update the background of the page with their favorite color.
function updateUserInfo(userInfo) {
if (userInfo?.name && userInfo?.color) {
banner.innerHTML = `Hello, ${userInfo.name}!`;
body.style.backgroundColor = userInfo?.color;
}
}
Conclusion
In this tutorial, you've learned how to securely authenticate a user using their client device to provide them with a personalized experience.
But what if you aren't able to bind devices to user accounts before they are distributed? In the second part of this tutorial, we'll cover how to use QR Code Authentication to let users sign in on a mobile phone to bind their Senza device to their account.
Updated about 2 months ago