mirror of
https://github.com/KingOfTheNOPs/DBSC-Example
synced 2026-06-29 08:59:52 +00:00
intial commit
This commit is contained in:
@@ -119,6 +119,14 @@ dist
|
|||||||
# Firebase cache directory
|
# Firebase cache directory
|
||||||
.firebase/
|
.firebase/
|
||||||
|
|
||||||
|
# DBSC POC runtime files
|
||||||
|
certs/
|
||||||
|
screenshot.png
|
||||||
|
server.out.log
|
||||||
|
server.err.log
|
||||||
|
edge-dbsc-profile/
|
||||||
|
chrome-dbsc-profile/
|
||||||
|
|
||||||
# TernJS port file
|
# TernJS port file
|
||||||
.tern-port
|
.tern-port
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,156 @@
|
|||||||
# DBSC-Example
|
# DBSC Example
|
||||||
Device Bound Session Cookie Example Application for Testing
|
|
||||||
|
A small HTTPS proof-of-concept web app for Device Bound Session Credentials
|
||||||
|
(DBSC). It demonstrates a login flow where the browser registers a device-bound
|
||||||
|
session key, receives a short-lived cookie, and silently refreshes that cookie
|
||||||
|
by proving possession of the DBSC private key.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- `POST /login` sends `Secure-Session-Registration`.
|
||||||
|
- `POST /dbsc/register` verifies the browser's ES256 `Secure-Session-Response`, stores the public key, and sets `__Host-dbsc_poc`.
|
||||||
|
- `POST /dbsc/refresh` verifies a signed refresh proof and rotates the cookie.
|
||||||
|
- `GET /protected` requires a valid DBSC-controlled cookie.
|
||||||
|
- `GET /device-bound` is only accessible with the current DBSC cookie.
|
||||||
|
- `GET /api/whoami` returns JSON showing whether the request is authenticated.
|
||||||
|
|
||||||
|
The cookie lifetime defaults to 60 seconds. Sessions are stored in memory, so
|
||||||
|
restarting the server requires logging in again.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 20 or newer
|
||||||
|
- OpenSSL, or Git for Windows, so the server can generate a local HTTPS cert
|
||||||
|
- Edge or Chrome with DBSC enabled
|
||||||
|
|
||||||
|
The app has no npm dependencies.
|
||||||
|
|
||||||
|
## Run The Server
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\AndrewGomez\Desktop\DBSC-Example
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent explicit command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .\server.js --host 0.0.0.0 --port 8443
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://localhost:8443/
|
||||||
|
```
|
||||||
|
|
||||||
|
To use a different lifetime:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node .\server.js --host 0.0.0.0 --port 8443 --cookie-max-age 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trust The Local Cert
|
||||||
|
|
||||||
|
On first run the server creates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
certs\dbsc-poc-cert.pem
|
||||||
|
certs\dbsc-poc-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the cert into the current user's trusted root store:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
certutil -user -addstore Root .\certs\dbsc-poc-cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launch Edge For Testing
|
||||||
|
|
||||||
|
Use the same origin consistently. Do not register on `localhost` and then test on
|
||||||
|
another hostname or IP address.
|
||||||
|
|
||||||
|
Recommended Edge launch flow:
|
||||||
|
|
||||||
|
1. Fully stop Edge.
|
||||||
|
2. Clear Edge's persisted DBSC state if you have restarted the Node server.
|
||||||
|
3. Relaunch Edge with DBSC enabled, DBSC persistence enabled, and the DBSC
|
||||||
|
refresh quota disabled for local testing.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Process msedge -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
|
|
||||||
|
Remove-Item "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Network\Device Bound Sessions*" -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Start-Process "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" -ArgumentList @(
|
||||||
|
"--remote-debugging-port=9333",
|
||||||
|
"--enable-features=DeviceBoundSessions,PersistDeviceBoundSessions",
|
||||||
|
"--disable-features=DeviceBoundSessionsRefreshQuota",
|
||||||
|
"https://localhost:8443/"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer Edge flags, enable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
edge://flags/#enable-standard-device-bound-session-credentials
|
||||||
|
edge://flags/#enable-standard-device-bound-session-persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
For this POC, disable the refresh quota while testing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
edge://flags/#enable-standard-device-bound-session-credentials-refresh-quota
|
||||||
|
```
|
||||||
|
|
||||||
|
Edge stores DBSC metadata separately from the normal cookie DB:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%LOCALAPPDATA%\Microsoft\Edge\User Data\Default\Network\Device Bound Sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
That store can survive a server restart. Because this example keeps sessions
|
||||||
|
only in memory, stale persisted DBSC sessions can make Edge send refreshes for
|
||||||
|
unknown session IDs. Clearing `Device Bound Sessions*` fixes that.
|
||||||
|
|
||||||
|
Use `/api/whoami` and the server event log to confirm whether the browser
|
||||||
|
successfully registered or refreshed a DBSC session.
|
||||||
|
|
||||||
|
## Test The API
|
||||||
|
|
||||||
|
After logging in from Edge:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl.exe -k -i https://localhost:8443/api/whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
`curl` will only show authenticated if you manually supply the current cookie:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$cookie = '__Host-dbsc_poc=PASTE_COOKIE_VALUE_HERE'
|
||||||
|
curl.exe -k -i -H "Cookie: $cookie" https://localhost:8443/api/whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
Once that cookie expires, `curl` cannot refresh it because it does not have the
|
||||||
|
browser's DBSC private key.
|
||||||
|
|
||||||
|
## Reset Edge DBSC State
|
||||||
|
|
||||||
|
If the server was restarted while Edge had persistent DBSC sessions, Edge may
|
||||||
|
try to refresh an old session ID. Close Edge and clear the DBSC store:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Process msedge -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
|
Remove-Item "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Network\Device Bound Sessions*" -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
Then relaunch Edge with the command above.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The server listens on `0.0.0.0` by default.
|
||||||
|
- Runtime certs, logs, screenshots, and browser test profiles are intentionally ignored by git.
|
||||||
|
- This POC verifies ES256/P-256 DBSC JWT signatures server-side, but it does not use a database.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
Based on the docs in https://github.com/w3c/webappsec-dbsc
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "dbsc-example",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Standalone Device Bound Session Credentials example web app.",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const https = require("node:https");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const PORT = Number(options.port || process.env.PORT || 8443);
|
||||||
|
const BIND_HOST = options.host || process.env.HOST || "0.0.0.0";
|
||||||
|
const COOKIE_MAX_AGE_SECONDS = Number(options.cookieMaxAge || process.env.COOKIE_MAX_AGE_SECONDS || 60);
|
||||||
|
const ALLOW_NONE_ALGORITHM = Boolean(options.allowNone || process.env.DBSC_ALLOW_NONE === "1");
|
||||||
|
const COOKIE_NAME = "__Host-dbsc_poc";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
pending: new Map(),
|
||||||
|
sessions: new Map(),
|
||||||
|
cookieIndex: new Map(),
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const parsed = {};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === "--allow-none") {
|
||||||
|
parsed.allowNone = true;
|
||||||
|
} else if (arg === "--port") {
|
||||||
|
parsed.port = argv[++i];
|
||||||
|
} else if (arg === "--host") {
|
||||||
|
parsed.host = argv[++i];
|
||||||
|
} else if (arg === "--cookie-max-age") {
|
||||||
|
parsed.cookieMaxAge = argv[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(message) {
|
||||||
|
const line = `${new Date().toISOString()} ${message}`;
|
||||||
|
state.events.push(line);
|
||||||
|
while (state.events.length > 80) state.events.shift();
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOpenSsl() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.OPENSSL,
|
||||||
|
"openssl",
|
||||||
|
"C:\\Program Files\\Git\\usr\\bin\\openssl.exe",
|
||||||
|
"C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = spawnSync(candidate, ["version"], { stdio: "ignore" });
|
||||||
|
if (result.status === 0) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCertificate() {
|
||||||
|
const certDir = path.join(__dirname, "certs");
|
||||||
|
const keyPath = path.join(certDir, "dbsc-poc-key.pem");
|
||||||
|
const certPath = path.join(certDir, "dbsc-poc-cert.pem");
|
||||||
|
|
||||||
|
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
||||||
|
fs.mkdirSync(certDir, { recursive: true });
|
||||||
|
const openssl = findOpenSsl();
|
||||||
|
if (!openssl) {
|
||||||
|
throw new Error("OpenSSL was not found. Install Git for Windows or set OPENSSL to an openssl.exe path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const altNames = [
|
||||||
|
"DNS:localhost",
|
||||||
|
`DNS:${os.hostname()}`,
|
||||||
|
"IP:127.0.0.1",
|
||||||
|
...getLocalIpv4Addresses().map((address) => `IP:${address}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = spawnSync(openssl, [
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-sha256",
|
||||||
|
"-days",
|
||||||
|
"365",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
keyPath,
|
||||||
|
"-out",
|
||||||
|
certPath,
|
||||||
|
"-subj",
|
||||||
|
"/CN=localhost",
|
||||||
|
"-addext",
|
||||||
|
`subjectAltName=${[...new Set(altNames)].join(",")}`,
|
||||||
|
], { stdio: "ignore" });
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error("OpenSSL failed to generate the localhost certificate.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: fs.readFileSync(keyPath),
|
||||||
|
cert: fs.readFileSync(certPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalIpv4Addresses() {
|
||||||
|
const addresses = [];
|
||||||
|
for (const interfaces of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const entry of interfaces || []) {
|
||||||
|
if (entry.family === "IPv4" && !entry.internal) {
|
||||||
|
addresses.push(entry.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOrigin(req) {
|
||||||
|
const host = req.headers.host || `localhost:${PORT}`;
|
||||||
|
return `https://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(bytes = 24) {
|
||||||
|
return crypto.randomBytes(bytes).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowSeconds() {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlEscape(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteSfString(value) {
|
||||||
|
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStructuredString(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const input = String(value).trim();
|
||||||
|
if (!input.startsWith('"')) return input.split(";", 1)[0].trim();
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = 1; i < input.length; i += 1) {
|
||||||
|
const ch = input[i];
|
||||||
|
if (escaped) {
|
||||||
|
output += ch;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') break;
|
||||||
|
output += ch;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(req) {
|
||||||
|
const cookies = new Map();
|
||||||
|
const raw = req.headers.cookie;
|
||||||
|
if (!raw) return cookies;
|
||||||
|
|
||||||
|
for (const part of raw.split(";")) {
|
||||||
|
const [name, ...rest] = part.trim().split("=");
|
||||||
|
if (!name) continue;
|
||||||
|
cookies.set(name, decodeURIComponent(rest.join("=")));
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneSessionCookies(session) {
|
||||||
|
const now = nowSeconds();
|
||||||
|
for (const [value, expires] of session.cookies) {
|
||||||
|
if (expires <= now) {
|
||||||
|
session.cookies.delete(value);
|
||||||
|
state.cookieIndex.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionFromCookie(req) {
|
||||||
|
const cookies = parseCookies(req);
|
||||||
|
const cookieValue = cookies.get(COOKIE_NAME);
|
||||||
|
if (!cookieValue) return null;
|
||||||
|
|
||||||
|
const sessionId = state.cookieIndex.get(cookieValue);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
|
const session = state.sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
pruneSessionCookies(session);
|
||||||
|
if (!session.cookies.has(cookieValue)) return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundCookie(value, maxAge = COOKIE_MAX_AGE_SECONDS) {
|
||||||
|
return `${COOKIE_NAME}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; Secure; HttpOnly; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionInstructions(session) {
|
||||||
|
return {
|
||||||
|
session_identifier: session.id,
|
||||||
|
refresh_url: "/dbsc/refresh",
|
||||||
|
scope: {
|
||||||
|
include_site: false,
|
||||||
|
scope_specification: [
|
||||||
|
{ type: "exclude", path: "/dbsc" },
|
||||||
|
{ type: "exclude", path: "/login" },
|
||||||
|
{ type: "exclude", path: "/logout" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
type: "cookie",
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
attributes: "Path=/; Secure; HttpOnly; SameSite=Lax",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowed_refresh_initiators: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwt(jwt) {
|
||||||
|
const parts = String(jwt).split(".");
|
||||||
|
if (parts.length !== 3) throw new Error("DBSC proof is not a compact JWS.");
|
||||||
|
return {
|
||||||
|
parts,
|
||||||
|
header: JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")),
|
||||||
|
payload: JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")),
|
||||||
|
signature: parts[2] ? Buffer.from(parts[2], "base64url") : Buffer.alloc(0),
|
||||||
|
signingInput: Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyDbscJwt({ jwt, expectedChallenge, expectedAuthorization = null, storedJwk = null, registration = false }) {
|
||||||
|
const proof = decodeJwt(jwt);
|
||||||
|
const { header, payload } = proof;
|
||||||
|
|
||||||
|
if (header.typ !== "dbsc+jwt") throw new Error("JWT typ must be dbsc+jwt.");
|
||||||
|
if (payload.jti !== expectedChallenge) throw new Error("JWT challenge mismatch.");
|
||||||
|
if (expectedAuthorization && payload.authorization !== expectedAuthorization) {
|
||||||
|
throw new Error("JWT authorization mismatch.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const alg = String(header.alg || "");
|
||||||
|
let jwk = storedJwk;
|
||||||
|
if (registration) {
|
||||||
|
if (alg !== "none" && !header.jwk) {
|
||||||
|
throw new Error("Registration proof must include the public JWK.");
|
||||||
|
}
|
||||||
|
jwk = header.jwk || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alg === "none") {
|
||||||
|
if (!ALLOW_NONE_ALGORITHM) throw new Error("alg=none is disabled.");
|
||||||
|
if (proof.signature.length !== 0) throw new Error("alg=none JWT must have an empty signature.");
|
||||||
|
return { header, payload, jwk, algorithm: alg };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alg !== "ES256") throw new Error(`Unsupported JWT alg '${alg}'.`);
|
||||||
|
if (!jwk) throw new Error("Missing JWK for ES256 verification.");
|
||||||
|
if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new Error("Only EC P-256 JWKs are supported.");
|
||||||
|
if (proof.signature.length !== 64) throw new Error("ES256 signature must be 64 bytes.");
|
||||||
|
|
||||||
|
const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
||||||
|
const ok = crypto.verify(
|
||||||
|
"sha256",
|
||||||
|
proof.signingInput,
|
||||||
|
{ key: publicKey, dsaEncoding: "ieee-p1363" },
|
||||||
|
proof.signature,
|
||||||
|
);
|
||||||
|
if (!ok) throw new Error("ES256 signature verification failed.");
|
||||||
|
return { header, payload, jwk, algorithm: alg };
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, body = "", contentType = "text/html; charset=utf-8", headers = {}) {
|
||||||
|
const bytes = Buffer.from(body);
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader("Content-Length", bytes.length);
|
||||||
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
|
res.setHeader(name, value);
|
||||||
|
}
|
||||||
|
res.end(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, value, headers = {}) {
|
||||||
|
send(res, status, JSON.stringify(value, null, 2), "application/json", headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(title, body) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${htmlEscape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f7f7f3;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--ink: #1c2522;
|
||||||
|
--muted: #5f6b66;
|
||||||
|
--line: #d9ded8;
|
||||||
|
--accent: #006b5f;
|
||||||
|
--accent-2: #b4492d;
|
||||||
|
--accent-3: #335c98;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font: 15px/1.45 "Segoe UI", system-ui, sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 34px 0;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.subtle { color: var(--muted); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 380px) 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
button, .button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.button.secondary, button.secondary { background: var(--accent-3); }
|
||||||
|
.button.warn, button.warn { background: var(--accent-2); }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
dt { color: var(--muted); }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font: 12px/1.45 Consolas, monospace;
|
||||||
|
color: #24332f;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin-top: 14px;
|
||||||
|
min-height: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
main { width: min(100vw - 24px, 980px); padding-top: 20px; }
|
||||||
|
header { align-items: flex-start; flex-direction: column; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
dl { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>${body}</main></body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLog() {
|
||||||
|
return htmlEscape(state.events.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginPage(origin, notice = "") {
|
||||||
|
return page("DBSC POC", `
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>DBSC POC</h1>
|
||||||
|
<div class="subtle">${htmlEscape(origin)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button secondary" href="/protected">Protected</a>
|
||||||
|
<a class="button secondary" href="/device-bound">Bound-only</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="grid">
|
||||||
|
<form class="panel" method="post" action="/login">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" autocomplete="username" value="demo">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" value="password">
|
||||||
|
<div class="actions"><button type="submit">Sign in</button></div>
|
||||||
|
<div class="status">${htmlEscape(notice)}</div>
|
||||||
|
</form>
|
||||||
|
<section class="panel"><pre>${eventLog()}</pre></section>
|
||||||
|
</section>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingLoginPage(username, origin) {
|
||||||
|
return page("DBSC POC", `
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>DBSC POC</h1>
|
||||||
|
<div class="subtle">Signed in as ${htmlEscape(username)}</div>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/protected">Protected</a>
|
||||||
|
</header>
|
||||||
|
<section class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<dl>
|
||||||
|
<dt>Registration</dt><dd id="state">waiting</dd>
|
||||||
|
<dt>Cookie lifetime</dt><dd>${COOKIE_MAX_AGE_SECONDS} seconds</dd>
|
||||||
|
<dt>Origin</dt><dd>${htmlEscape(origin)}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button" href="/protected">Open protected view</a>
|
||||||
|
<a class="button secondary" href="/device-bound">Open bound-only page</a>
|
||||||
|
<a class="button warn" href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><pre>${eventLog()}</pre></section>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
const state = document.getElementById("state");
|
||||||
|
let attempts = 0;
|
||||||
|
async function poll() {
|
||||||
|
attempts += 1;
|
||||||
|
const res = await fetch("/api/status", { credentials: "include" });
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.authenticated) {
|
||||||
|
location.href = "/protected";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.textContent = attempts > 8 ? "not registered" : "waiting";
|
||||||
|
if (attempts < 20) setTimeout(poll, 600);
|
||||||
|
}
|
||||||
|
setTimeout(poll, 450);
|
||||||
|
</script>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function protectedPage(session) {
|
||||||
|
const ttl = Math.max(0, session.cookieExpires - nowSeconds());
|
||||||
|
return page("Protected", `
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Protected</h1>
|
||||||
|
<div class="subtle">Bound cookie accepted</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/expire"><button class="secondary" type="submit">Expire cookie</button></form>
|
||||||
|
<a class="button warn" href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<dl>
|
||||||
|
<dt>DBSC session</dt><dd>${htmlEscape(session.id)}</dd>
|
||||||
|
<dt>JWT alg</dt><dd>${htmlEscape(session.algorithm)}</dd>
|
||||||
|
<dt>Cookie TTL</dt><dd>${ttl} seconds</dd>
|
||||||
|
<dt>Refresh endpoint</dt><dd>/dbsc/refresh</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button" href="/protected">Reload</a>
|
||||||
|
<a class="button secondary" href="/device-bound">Bound-only page</a>
|
||||||
|
<a class="button secondary" href="/api/whoami">Whoami JSON</a>
|
||||||
|
<a class="button secondary" href="/api/status">Status JSON</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><pre>${eventLog()}</pre></section>
|
||||||
|
</section>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceBoundPage(session) {
|
||||||
|
const ttl = Math.max(0, session.cookieExpires - nowSeconds());
|
||||||
|
return page("Device Bound", `
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Device Bound</h1>
|
||||||
|
<div class="subtle">DBSC cookie required</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button secondary" href="/protected">Protected</a>
|
||||||
|
<a class="button warn" href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<dl>
|
||||||
|
<dt>User</dt><dd>${htmlEscape(session.username)}</dd>
|
||||||
|
<dt>DBSC session</dt><dd>${htmlEscape(session.id)}</dd>
|
||||||
|
<dt>Cookie name</dt><dd>${COOKIE_NAME}</dd>
|
||||||
|
<dt>Cookie TTL</dt><dd>${ttl} seconds</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/expire"><button class="secondary" type="submit">Expire cookie</button></form>
|
||||||
|
<a class="button" href="/device-bound">Reload</a>
|
||||||
|
<a class="button secondary" href="/api/whoami">Whoami JSON</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><pre>${eventLog()}</pre></section>
|
||||||
|
</section>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(req, res) {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const form = new URLSearchParams(body);
|
||||||
|
const username = form.get("username") || "demo";
|
||||||
|
const challenge = token();
|
||||||
|
state.pending.set(challenge, {
|
||||||
|
challenge,
|
||||||
|
username,
|
||||||
|
created: nowSeconds(),
|
||||||
|
});
|
||||||
|
|
||||||
|
addEvent(`login accepted for '${username}'; sent Secure-Session-Registration`);
|
||||||
|
send(res, 200, pendingLoginPage(username, requestOrigin(req)), "text/html; charset=utf-8", {
|
||||||
|
"Secure-Session-Registration": `(ES256);path=${quoteSfString("/dbsc/register")};challenge=${quoteSfString(challenge)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister(req, res) {
|
||||||
|
try {
|
||||||
|
const jwt = parseStructuredString(req.headers["secure-session-response"]);
|
||||||
|
if (!jwt) throw new Error("Missing Secure-Session-Response.");
|
||||||
|
const unverified = decodeJwt(jwt);
|
||||||
|
const pending = state.pending.get(unverified.payload.jti);
|
||||||
|
if (!pending) throw new Error("Missing or unknown registration challenge.");
|
||||||
|
|
||||||
|
const proof = verifyDbscJwt({
|
||||||
|
jwt,
|
||||||
|
expectedChallenge: pending.challenge,
|
||||||
|
registration: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = token(18);
|
||||||
|
const cookieValue = token(32);
|
||||||
|
const nextChallenge = token();
|
||||||
|
const session = {
|
||||||
|
id: sessionId,
|
||||||
|
username: pending.username,
|
||||||
|
jwk: proof.jwk,
|
||||||
|
algorithm: proof.algorithm,
|
||||||
|
cookieValue,
|
||||||
|
cookieExpires: nowSeconds() + COOKIE_MAX_AGE_SECONDS,
|
||||||
|
cookies: new Map([[cookieValue, nowSeconds() + COOKIE_MAX_AGE_SECONDS]]),
|
||||||
|
challenge: nextChallenge,
|
||||||
|
challengeExpires: nowSeconds() + 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.sessions.set(sessionId, session);
|
||||||
|
state.cookieIndex.set(cookieValue, sessionId);
|
||||||
|
state.pending.delete(pending.challenge);
|
||||||
|
|
||||||
|
addEvent(`registered DBSC session ${sessionId} with ${proof.algorithm}`);
|
||||||
|
sendJson(res, 200, sessionInstructions(session), {
|
||||||
|
"Set-Cookie": boundCookie(cookieValue),
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(nextChallenge)};id=${quoteSfString(sessionId)}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
addEvent(`registration failed: ${error.message}`);
|
||||||
|
sendJson(res, 400, { error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefresh(req, res) {
|
||||||
|
try {
|
||||||
|
const sessionId = parseStructuredString(req.headers["sec-secure-session-id"]);
|
||||||
|
const session = sessionId ? state.sessions.get(sessionId) : null;
|
||||||
|
if (!session) throw new Error("Unknown Sec-Secure-Session-Id.");
|
||||||
|
|
||||||
|
const jwt = parseStructuredString(req.headers["secure-session-response"]);
|
||||||
|
if (!jwt) {
|
||||||
|
session.challenge = token();
|
||||||
|
session.challengeExpires = nowSeconds() + 90;
|
||||||
|
addEvent(`refresh challenge issued for ${session.id}`);
|
||||||
|
send(res, 403, "", "text/plain; charset=utf-8", {
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.challengeExpires <= nowSeconds()) {
|
||||||
|
throw new Error("Refresh challenge expired.");
|
||||||
|
}
|
||||||
|
verifyDbscJwt({
|
||||||
|
jwt,
|
||||||
|
expectedChallenge: session.challenge,
|
||||||
|
storedJwk: session.jwk,
|
||||||
|
});
|
||||||
|
|
||||||
|
pruneSessionCookies(session);
|
||||||
|
session.cookieValue = token(32);
|
||||||
|
session.cookieExpires = nowSeconds() + COOKIE_MAX_AGE_SECONDS;
|
||||||
|
session.cookies.set(session.cookieValue, session.cookieExpires);
|
||||||
|
session.challenge = token();
|
||||||
|
session.challengeExpires = nowSeconds() + 90;
|
||||||
|
state.cookieIndex.set(session.cookieValue, session.id);
|
||||||
|
|
||||||
|
addEvent(`refreshed bound cookie for ${session.id}`);
|
||||||
|
sendJson(res, 200, sessionInstructions(session), {
|
||||||
|
"Set-Cookie": boundCookie(session.cookieValue),
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
addEvent(`refresh failed: ${error.message}`);
|
||||||
|
sendJson(res, 403, { continue: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatus(req, res) {
|
||||||
|
const session = getSessionFromCookie(req);
|
||||||
|
if (!session) {
|
||||||
|
sendJson(res, 401, { authenticated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
authenticated: true,
|
||||||
|
session_identifier: session.id,
|
||||||
|
cookie_ttl_seconds: Math.max(0, session.cookieExpires - nowSeconds()),
|
||||||
|
}, {
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWhoami(req, res) {
|
||||||
|
const session = getSessionFromCookie(req);
|
||||||
|
if (!session) {
|
||||||
|
if (req.headers["secure-session-skipped"]) {
|
||||||
|
addEvent(`whoami request arrived with Secure-Session-Skipped: ${req.headers["secure-session-skipped"]}`);
|
||||||
|
}
|
||||||
|
sendJson(res, 401, {
|
||||||
|
authenticated: false,
|
||||||
|
dbsc: {
|
||||||
|
active: false,
|
||||||
|
required_cookie: COOKIE_NAME,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, 200, {
|
||||||
|
authenticated: true,
|
||||||
|
username: session.username,
|
||||||
|
dbsc: {
|
||||||
|
active: true,
|
||||||
|
session_identifier: session.id,
|
||||||
|
cookie_name: COOKIE_NAME,
|
||||||
|
cookie_ttl_seconds: Math.max(0, session.cookieExpires - nowSeconds()),
|
||||||
|
proof_algorithm: session.algorithm,
|
||||||
|
refresh_url: "/dbsc/refresh",
|
||||||
|
accepted_cookie_count: session.cookies.size,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProtected(req, res) {
|
||||||
|
const session = getSessionFromCookie(req);
|
||||||
|
if (!session) {
|
||||||
|
if (req.headers["secure-session-skipped"]) {
|
||||||
|
addEvent(`protected request arrived with Secure-Session-Skipped: ${req.headers["secure-session-skipped"]}`);
|
||||||
|
}
|
||||||
|
send(res, 401, loginPage(requestOrigin(req), "No valid bound cookie on this request."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(res, 200, protectedPage(session), "text/html; charset=utf-8", {
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeviceBound(req, res) {
|
||||||
|
const session = getSessionFromCookie(req);
|
||||||
|
if (!session) {
|
||||||
|
if (req.headers["secure-session-skipped"]) {
|
||||||
|
addEvent(`device-bound request arrived with Secure-Session-Skipped: ${req.headers["secure-session-skipped"]}`);
|
||||||
|
}
|
||||||
|
send(res, 401, loginPage(requestOrigin(req), "A current device-bound session cookie is required."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(res, 200, deviceBoundPage(session), "text/html; charset=utf-8", {
|
||||||
|
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExpire(req, res) {
|
||||||
|
const session = getSessionFromCookie(req);
|
||||||
|
if (session) {
|
||||||
|
const presentedCookie = parseCookies(req).get(COOKIE_NAME);
|
||||||
|
if (presentedCookie) {
|
||||||
|
session.cookies.delete(presentedCookie);
|
||||||
|
state.cookieIndex.delete(presentedCookie);
|
||||||
|
}
|
||||||
|
if (presentedCookie === session.cookieValue) {
|
||||||
|
session.cookieExpires = nowSeconds() - 1;
|
||||||
|
}
|
||||||
|
addEvent(`expired bound cookie for ${session.id}`);
|
||||||
|
}
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: "/protected",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Set-Cookie": boundCookie("expired", 0),
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout(req, res) {
|
||||||
|
state.sessions.clear();
|
||||||
|
state.cookieIndex.clear();
|
||||||
|
addEvent("cleared local sessions");
|
||||||
|
res.writeHead(302, {
|
||||||
|
Location: "/",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Set-Cookie": boundCookie("logged-out", 0),
|
||||||
|
"Clear-Site-Data": '"cookies"',
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tlsOptions = ensureCertificate();
|
||||||
|
|
||||||
|
const server = https.createServer(tlsOptions, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, requestOrigin(req));
|
||||||
|
const route = `${req.method} ${url.pathname}`;
|
||||||
|
|
||||||
|
if (route === "GET /") return send(res, 200, loginPage(requestOrigin(req)));
|
||||||
|
if (route === "POST /login") return handleLogin(req, res);
|
||||||
|
if (route === "POST /dbsc/register") return handleRegister(req, res);
|
||||||
|
if (route === "POST /dbsc/refresh") return handleRefresh(req, res);
|
||||||
|
if (route === "GET /api/status") return handleStatus(req, res);
|
||||||
|
if (route === "GET /api/whoami") return handleWhoami(req, res);
|
||||||
|
if (route === "GET /protected") return handleProtected(req, res);
|
||||||
|
if (route === "GET /device-bound") return handleDeviceBound(req, res);
|
||||||
|
if (route === "POST /expire") return handleExpire(req, res);
|
||||||
|
if (route === "GET /logout") return handleLogout(req, res);
|
||||||
|
|
||||||
|
send(res, 404, "Not found", "text/plain; charset=utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
addEvent(`request failed: ${error.stack || error.message}`);
|
||||||
|
send(res, 500, "Internal Server Error", "text/plain; charset=utf-8");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, BIND_HOST, () => {
|
||||||
|
addEvent(`DBSC POC listening on ${BIND_HOST}:${PORT}`);
|
||||||
|
addEvent(`open https://localhost:${PORT}/ or https://<this-host-ip>:${PORT}/`);
|
||||||
|
addEvent(`bound cookies last ${COOKIE_MAX_AGE_SECONDS} seconds`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user