226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import type { AddressInfo } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
import express from "express";
|
|
import { closeDatabase } from "../../src/db/sqlite.js";
|
|
import { requireSmsActionJwt, sha256Hex } from "../../src/middleware/smsActionJwt.js";
|
|
|
|
const USER = "did:ethr:0xclaimuser";
|
|
const PHONE = "+15555550123";
|
|
|
|
const ENV_KEYS = [
|
|
"SMS_REQUIRE_ACTION_CLAIM",
|
|
"SMS_ACTION_JWT_MAX_AGE_SEC",
|
|
"NOTIFY_DATA_DIR",
|
|
] as const;
|
|
|
|
let dir: string;
|
|
let savedEnv: Record<string, string | undefined>;
|
|
let server: { url: string; close: () => Promise<void> };
|
|
let handlerRuns: number;
|
|
/** Set to false to mimic a route mounted without requireAuth. */
|
|
let authenticate: boolean;
|
|
|
|
beforeEach(async () => {
|
|
savedEnv = {};
|
|
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
|
|
dir = await mkdtemp(path.join(tmpdir(), "sms-action-jwt-"));
|
|
process.env.NOTIFY_DATA_DIR = dir;
|
|
closeDatabase();
|
|
handlerRuns = 0;
|
|
authenticate = true;
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.post(
|
|
"/verify",
|
|
(req, _res, next) => {
|
|
if (!authenticate) {
|
|
next();
|
|
return;
|
|
}
|
|
const parsed = JSON.parse(req.get("X-Test-Auth") as string) as {
|
|
jwt: string;
|
|
payload: Record<string, unknown>;
|
|
};
|
|
req.did = USER;
|
|
req.jwt = parsed.jwt;
|
|
req.auth = { did: USER, jwt: parsed.jwt, payload: parsed.payload };
|
|
next();
|
|
},
|
|
requireSmsActionJwt("verify-phone"),
|
|
(req, res) => {
|
|
handlerRuns += 1;
|
|
res.status(200).json({ ok: true, jwtHash: req.smsActionJwtHash });
|
|
}
|
|
);
|
|
const listening = app.listen(0);
|
|
await new Promise((resolve) => listening.once("listening", resolve));
|
|
const port = (listening.address() as AddressInfo).port;
|
|
server = {
|
|
url: `http://127.0.0.1:${port}/verify`,
|
|
close: () => new Promise<void>((r) => listening.close(() => r())),
|
|
};
|
|
});
|
|
|
|
afterEach(async () => {
|
|
closeDatabase();
|
|
await server.close();
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) delete process.env[key];
|
|
else process.env[key] = savedEnv[key];
|
|
}
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
let counter = 0;
|
|
|
|
async function post(input: {
|
|
payload?: Record<string, unknown>;
|
|
jwt?: string;
|
|
body?: unknown;
|
|
}): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
counter += 1;
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
if (authenticate) {
|
|
headers["X-Test-Auth"] = JSON.stringify({
|
|
jwt: input.jwt ?? `token-${counter}`,
|
|
payload: input.payload ?? {},
|
|
});
|
|
}
|
|
const response = await fetch(server.url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(input.body ?? { phoneNumber: PHONE }),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
}
|
|
|
|
function payload(
|
|
overrides: Record<string, unknown> = {},
|
|
claim: Record<string, unknown> | null = {
|
|
action: "verify-phone",
|
|
phoneNumber: PHONE,
|
|
}
|
|
): Record<string, unknown> {
|
|
return {
|
|
iss: USER,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
...(claim === null ? {} : { claim }),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("requireSmsActionJwt", () => {
|
|
it("passes a fresh, correctly bound token and records its hash", async () => {
|
|
const result = await post({ payload: payload(), jwt: "the-token" });
|
|
assert.equal(result.status, 200);
|
|
assert.equal(handlerRuns, 1);
|
|
assert.equal(result.body.jwtHash, sha256Hex("the-token"));
|
|
});
|
|
|
|
it("fails closed with 500 when the route is not authenticated", async () => {
|
|
authenticate = false;
|
|
const result = await post({});
|
|
assert.equal(result.status, 500);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_NOT_AUTHENTICATED");
|
|
assert.equal(handlerRuns, 0);
|
|
});
|
|
|
|
it("rejects a token with no claim", async () => {
|
|
const result = await post({ payload: payload({}, null) });
|
|
assert.equal(result.status, 403);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
|
|
assert.equal(handlerRuns, 0);
|
|
});
|
|
|
|
it("rejects a claim that is not an object", async () => {
|
|
const result = await post({ payload: { iss: USER, iat: 1, claim: "nope" } });
|
|
assert.equal(result.status, 403);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
|
|
});
|
|
|
|
it("rejects a claim authorizing a different action", async () => {
|
|
const result = await post({
|
|
payload: payload({}, { action: "delete-phone", phoneNumber: PHONE }),
|
|
});
|
|
assert.equal(result.status, 403);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
|
|
});
|
|
|
|
it("rejects a claim naming a different phone", async () => {
|
|
const result = await post({
|
|
payload: payload({}, { action: "verify-phone", phoneNumber: "+15555559999" }),
|
|
});
|
|
assert.equal(result.status, 403);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
|
|
});
|
|
|
|
it("matches the claim's phone after normalization, not by string", async () => {
|
|
const result = await post({
|
|
payload: payload({}, { action: "verify-phone", phoneNumber: "(555) 555-0123" }),
|
|
body: { phoneNumber: "555.555.0123" },
|
|
});
|
|
assert.equal(result.status, 200);
|
|
});
|
|
|
|
it("rejects a claim carrying no phone at all", async () => {
|
|
const result = await post({
|
|
payload: payload({}, { action: "verify-phone" }),
|
|
});
|
|
assert.equal(result.status, 403);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
|
|
});
|
|
|
|
it("rejects a token issued too long ago", async () => {
|
|
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
|
|
const result = await post({
|
|
payload: payload({ iat: Math.floor(Date.now() / 1000) - 120 }),
|
|
});
|
|
assert.equal(result.status, 401);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
|
|
});
|
|
|
|
it("rejects a token with no iat", async () => {
|
|
const result = await post({ payload: payload({ iat: undefined }) });
|
|
assert.equal(result.status, 401);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
|
|
});
|
|
|
|
it("rejects an expired token", async () => {
|
|
const result = await post({
|
|
payload: payload({ exp: Math.floor(Date.now() / 1000) - 1 }),
|
|
});
|
|
assert.equal(result.status, 401);
|
|
assert.equal(result.body.error, "SMS_ACTION_JWT_EXPIRED");
|
|
});
|
|
|
|
it("accepts a token whose exp is still ahead", async () => {
|
|
const result = await post({
|
|
payload: payload({ exp: Math.floor(Date.now() / 1000) + 300 }),
|
|
});
|
|
assert.equal(result.status, 200);
|
|
});
|
|
|
|
it("rejects the same token used a second time", async () => {
|
|
const first = await post({ payload: payload(), jwt: "one-shot" });
|
|
assert.equal(first.status, 200);
|
|
const second = await post({ payload: payload(), jwt: "one-shot" });
|
|
assert.equal(second.status, 401);
|
|
assert.equal(second.body.error, "SMS_ACTION_JWT_REPLAYED");
|
|
assert.equal(handlerRuns, 1);
|
|
});
|
|
|
|
it("skips every check when the claim is not required", async () => {
|
|
process.env.SMS_REQUIRE_ACTION_CLAIM = "false";
|
|
const result = await post({ payload: payload({}, null) });
|
|
assert.equal(result.status, 200);
|
|
assert.equal(result.body.jwtHash, undefined);
|
|
});
|
|
});
|