visit
How would we build an experience like the one above?
Some applications need to limit users to a single client or browser instance. This post covers how to build, improve, and scale this feature. We begin with a simple web app with two API endpoints:
curl -H "user:user123" localhost:9000/login
{"sessionId":"364rl8"}
The user adds
sessionid=364rl8
as an HTTP header for the route /api
. If the session ID is valid, the server returns “authenticated”, if not, the server returns an error:curl -H "sessionid=364rl8" localhost:9000/api
authenticated
curl -H "sessionid=badSession" localhost:9000/api
error: invalid session
Note: our example returns the session ID in the HTTP response body, but it’s more common in practice to store the session ID as a cookie, where the server returns the
Set-Cookie: sessionid=364rl8
HTTP header. This causes the browser to automatically include the session ID in all subsequent requests to the same domain.const { generateSessionId } = require("./utils");
const cors = require("cors");
const app = require("express")().use(cors());
const PORT = 9000;
// this will totally scale, trust me
const sessions = {};
app.get("/login", (req, res) => {
const { user } = req.headers;
if (!user) {
res.status(400).send("error: request must include the 'user' HTTP header");
} else {
const sessionId = generateSessionId();
sessions[user] = sessionId;
res.send({ sessionId });
}
});
app.get("/api", (req, res) => {
const { sessionid } = req.headers;
if (!sessionid) {
res.status(401).send("error: no sessionId. Log in at /login");
} else {
if (Object.values(sessions).includes(sessionid)) {
res.send("authenticated");
} else {
res.status(401).send("error: invalid session.");
}
}
});
app.listen(PORT, () => {
console.log(`server started on //localhost:${PORT}`);
});
async function logIn(userId, onSessionInvalidated)
The
logIn
function takes a callback function (as the second argument) that will be invoked whenever we detect that the session is no longer valid. We can implement this API in two ways: polling and server-push.async function logIn(userId, onSessionInvalidated) {
const response = await fetch("//localhost:9000/login", {
headers: {
user: userId,
},
});
const { sessionId } = await response.json();
const POLLING_INTERVAL = 200;
const poll = setInterval(async () => {
const response = await fetch("//localhost:9000/api", {
headers: {
sessionId,
},
});
if (response.status !== 200) {
// non-200 status code means the token is invalid
clearTimeout(poll);
onSessionInvalidated();
}
}, POLLING_INTERVAL);
return sessionId;
}
const wss = new WebSocket.Server({ port: 9001 });
wss.on("connection", (ws) => {
ws.on("message", (data) => {
const request = JSON.parse(data);
if (request.action === "subscribeToSessionInvalidation") {
const { sessionId } = request.args;
subscribeToSessionInvalidation(sessionId, () => {
ws.send(
JSON.stringify({
event: "sessionInvalidated",
args: {
sessionId,
},
})
);
});
}
});
});
{
action: "action ID",
args: {...}
}
If the
action
value is "subscribeToSessionInvalidation"
, notify that client whenever the specified session ID is invalidated.Note: this solution requires generating session IDs that are hard to guess.
We also need to update our
logIn
route handler to detect existing sessions and publish the invalidation event:app.get("/login", (req, res) => {
const { user } = req.headers;
if (!user) {
res.status(400).send("error: request must include the 'user' HTTP header");
} else {
const existingSession = sessions[user];
if (existingSession) {
publishSessionInvalidation(existingSession);
}
const sessionId = generateSessionId();
sessions[user] = sessionId;
res.send({ sessionId });
}
});
subscribeToSessionInvalidation
and publishSessionInvalidation
:const { EventEmitter } = require("events");
const sessionEvents = new EventEmitter();
const SESSION_INVALIDATED = "session_invalidated";
function publishSessionInvalidation(sessionId) {
sessionEvents.emit(SESSION_INVALIDATED, sessionId);
}
function subscribeToSessionInvalidation(sessionId, callback) {
const listener = (invalidatedSessionId) => {
if (sessionId === invalidatedSessionId) {
sessionEvents.removeListener(SESSION_INVALIDATED, listener);
callback();
}
};
sessionEvents.addListener(SESSION_INVALIDATED, listener);
}
module.exports = {
publishSessionInvalidation,
subscribeToSessionInvalidation,
};
async function logIn(userId, onSessionInvalidated) {
const response = await fetch("//localhost:9000/login", {
headers: {
user: userId,
},
});
const { sessionId } = await response.json();
const socket = new WebSocket("ws://localhost:9001");
socket.addEventListener("open", () => {
console.log("connected.");
socket.addEventListener("message", ({ data }) => {
const { event, args } = JSON.parse(data);
if (event === "sessionInvalidated") {
// args.sessionId should equal sessionId
onSessionInvalidated();
}
});
socket.send(
JSON.stringify({
action: "subscribeToSessionInvalidation",
args: {
sessionId,
},
})
);
});
socket.addEventListener("error", (error) => {
console.error(error);
});
return sessionId;
}
Load
/push/index.html
in your browser, and try it out. You should now see some real-time session invalidation action.docker run -d -p 6739:6739 redis
// remoteCache.js
const redis = require("redis");
const SessionCacheKey = "sessions";
client = redis.createClient({
host: process.env.REDIS_HOST
});
async function getSession(userId) {
return new Promise((resolve) => {
return client.hmget(SessionCacheKey, userId, (err, res) => {
resolve(res ? (Array.isArray(res) ? res[0] : res) : null);
});
});
}
async function putSession(userId, sessionId) {
return new Promise((resolve) => {
client.hmset(SessionCacheKey, userId, sessionId, (err, res) => {
resolve(res ? (Array.isArray(res) ? res[0] : res) : null);
});
});
}
We use the Redis commands and (HM stands for "hash map") to respectively read and write the tuple
[user ID, session ID]
. That takes care of the session storage, we still need to replace event emitter with Redis. The docs state:When a client issues a SUBSCRIBE or PSUBSCRIBE, that connection is put into a "subscriber" mode. At that point, the only valid commands are those that modify the subscription set, and quit (also ping on some redis versions). When the subscription set is empty, the connection is put back into regular mode.So we need to create two Redis clients, one for general commands, the other for dedicated subscriber commands:
// remoteCache.js
const SessionInvalidationChannel = "sessionInvalidation";
const pendingCallbacks = {};
async function connect() {
client = redis.createClient({
host: process.env.REDIS_HOST
});
// the redis client we're using works in two modes "normal" and
// "subscriber". So we duplicate a client here and use that
// for our subscriptions.
subscriber = client.duplicate();
return Promise.all([
new Promise((resolve) => {
client.on("ready", () => resolve());
}),
new Promise((resolve) => {
subscriber.on("ready", () => {
subscriber.on("message", (channel, invalidatedSession) => {
console.log(channel, invalidatedSession);
if (Object.keys(pendingCallbacks).includes(invalidatedSession)) {
pendingCallbacks[invalidatedSession]();
delete pendingCallbacks[invalidatedSession];
}
});
subscriber.subscribe(SessionInvalidationChannel, () => {
resolve();
});
});
}),
]);
}
function publishSessionInvalidation(sessionId) {
client.publish(SessionInvalidationChannel, sessionId);
}
function subscribeToSessionInvalidation(sessionId, callback) {
pendingCallbacks[sessionId] = callback;
}
In the
connect
function, we subscribe to the "sessionInvalidation"
channel. We publish to this channel when another module calls publishSessionInvalidation
.You can run the demo like so:git clone //github.com/robzhu/logged-out
cd logged-out/push-redis/server
npm i && node server.js
Next, open
/push-redis/index.html
in two browser tabs and you should be able to see the working demo.using System;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Websocket.Client;
static class Program
{
const string LoginEndpoint = "//localhost:9000/login";
const string UserID = "1234";
static Uri WebSocketEndpoint = new Uri("ws://localhost:9001");
static async Task Main(string[] args)
{
HttpClient client = new HttpClient();
client.DefaultRequestHeaders.Add("user", UserID);
dynamic response = JsonConvert.DeserializeObject(await client.GetStringAsync(LoginEndpoint));
string sessionId = response.sessionId;
Console.WriteLine("Obtained session ID: " + sessionId);
using (var socket = new WebsocketClient(WebSocketEndpoint))
{
await socket.Start();
socket.MessageReceived.Subscribe(msg =>
{
dynamic payload = JsonConvert.DeserializeObject(msg.Text);
if (payload["event"] == "sessionInvalidated")
{
Console.WriteLine("You have logged in elsewhere. Exiting.");
Environment.Exit(0);
}
});
socket.Send(JsonConvert.SerializeObject(new
{
action = "subscribeToSessionInvalidation",
args = new
{
sessionId = sessionId
}
}));
Console.WriteLine("Press ENTER to exit.");
Console.ReadLine();
}
}
}
You can run the .net client and web client side by side and watch them invalidate one another.
Of the many rough edges in the demo, the lack of type safety around the API stands out to me. Specifically, the topic names and the schema for the subscription request and response. Scaling this solution beyond one developer would require comprehensive documentation or a client-server type system, like a GraphQL schema.
Previously published at