Add PUT /notifications/alert-authorization so the app can install a 100-JWT delegated batch without overloading device registration.
Store the batch atomically under the authenticated DID, replace unused JWTs on each setup, and reject Passkey identities that cannot mint future credentials.
This commit is contained in:
Binary file not shown.
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [0.1.5] - 2026.08.26
|
||||
### Added
|
||||
- `PUT /notifications/alert-authorization` stores a 100-JWT delegated alert-authorization batch per user (SQLite), authenticated with the current user JWT
|
||||
|
||||
|
||||
## [0.1.4] - 2026.08.07
|
||||
### Changed
|
||||
- Renamed `FCM_TOKEN_DATA_DIR` env var to `NOTIFY_DATA_DIR`
|
||||
|
||||
@@ -28,10 +28,38 @@ On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the S
|
||||
|
||||
`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT.
|
||||
|
||||
**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before.
|
||||
`PUT /notifications/alert-authorization` uses the same current-user Bearer JWT + Endorser check. It does **not** accept the `testMode` local bypass. The 100 delegated JWTs in the body are stored credentials, not the request authenticator.
|
||||
|
||||
**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. This applies to register/refresh only.
|
||||
|
||||
Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification during local development (this is separate from the `testMode` bypass above).
|
||||
|
||||
### Alert authorization
|
||||
|
||||
`PUT /notifications/alert-authorization`
|
||||
|
||||
```
|
||||
Authorization: Bearer <current-user-JWT>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"batchId": "client-batch-id",
|
||||
"timezone": "America/Denver",
|
||||
"jwts": [
|
||||
{
|
||||
"sequence": 0,
|
||||
"day": "2026-08-27",
|
||||
"nbf": 1756270800,
|
||||
"exp": 1756357200,
|
||||
"jwt": "eyJ..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`timezone` is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
|
||||
|
||||
## Storage
|
||||
|
||||
### Database location
|
||||
@@ -54,7 +82,14 @@ Table `fcm_registrations` holds one row per registered device:
|
||||
|
||||
Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`, `fcm_token`, and `(user_id, fcm_token)`.
|
||||
|
||||
The schema is created automatically on startup if the database or tables do not already exist.
|
||||
Tables `alert_authorization_batches` and `alert_authorization_jwts` hold a user's delegated notification-JWT inventory (separate from device registration):
|
||||
|
||||
- Batch: `id`, `user_id` (authenticated DID), `batch_id`, `timezone` (IANA name at mint time), `created_at`
|
||||
- JWT: `batch_pk`, `sequence`, `day` (`YYYY-MM-DD`), `jwt`, `nbf`, `exp`, `status` (`unused` / `consumed`), `consumed_at`, timestamps
|
||||
|
||||
Unique on `(batch_pk, sequence)` and on `(user_id, day)` for unused rows. Indexes also exist on `(user_id, status)`, `(user_id, day)`, and `batch_pk`.
|
||||
|
||||
The schema is created automatically on startup if the database or tables do not already exist. New tables are added with `CREATE TABLE IF NOT EXISTS`; existing `fcm_registrations` rows are not migrated or altered.
|
||||
|
||||
### JSON → SQLite
|
||||
|
||||
@@ -96,5 +131,5 @@ Required environment variables:
|
||||
|---|---|
|
||||
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
|
||||
| `PORT` | HTTP port (default: `3003`). |
|
||||
| `ENDORSER_URL` | Endorser API base URL used for auth checks on register/refresh (default: `https://api.endorser.ch`). |
|
||||
| `ENDORSER_URL` | Endorser API base URL used for auth checks on register, refresh, and alert-authorization (default: `https://api.endorser.ch`). |
|
||||
| `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "notification-wakeup-service",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDatabase } from "./sqlite.js";
|
||||
|
||||
export const ALERT_JWT_STATUS_UNUSED = "unused";
|
||||
export const ALERT_JWT_STATUS_CONSUMED = "consumed";
|
||||
|
||||
export const EXPECTED_ALERT_JWT_BATCH_SIZE = 100;
|
||||
|
||||
export type AlertAuthorizationBatchRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
batchId: string;
|
||||
timezone: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AlertAuthorizationJwtRecord = {
|
||||
id: string;
|
||||
batchPk: string;
|
||||
userId: string;
|
||||
batchId: string;
|
||||
sequence: number;
|
||||
day: string;
|
||||
jwt: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
status: string;
|
||||
consumedAt?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type AlertAuthorizationJwtInput = {
|
||||
sequence: number;
|
||||
day: string;
|
||||
jwt: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
type BatchDbRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
batch_id: string;
|
||||
timezone: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type JwtDbRow = {
|
||||
id: string;
|
||||
batch_pk: string;
|
||||
user_id: string;
|
||||
batch_id: string;
|
||||
sequence: number;
|
||||
day: string;
|
||||
jwt: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
status: string;
|
||||
consumed_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const JWT_COLUMNS =
|
||||
"id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp, status, consumed_at, created_at";
|
||||
|
||||
function toJwtRecord(row: JwtDbRow): AlertAuthorizationJwtRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
batchPk: row.batch_pk,
|
||||
userId: row.user_id,
|
||||
batchId: row.batch_id,
|
||||
sequence: row.sequence,
|
||||
day: row.day,
|
||||
jwt: row.jwt,
|
||||
nbf: row.nbf,
|
||||
exp: row.exp,
|
||||
status: row.status,
|
||||
consumedAt: row.consumed_at ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toBatchRecord(row: BatchDbRow): AlertAuthorizationBatchRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
batchId: row.batch_id,
|
||||
timezone: row.timezone,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const alertAuthorizationDb = {
|
||||
/**
|
||||
* Atomically drop this user's unused JWTs (and empty batch rows), then install
|
||||
* a new batch. Consumed JWTs from prior batches are left in place.
|
||||
*/
|
||||
async replaceUnusedBatch(input: {
|
||||
userId: string;
|
||||
batchId: string;
|
||||
timezone: string;
|
||||
jwts: AlertAuthorizationJwtInput[];
|
||||
}): Promise<{
|
||||
batch: AlertAuthorizationBatchRecord;
|
||||
storedCount: number;
|
||||
unusedCount: number;
|
||||
}> {
|
||||
const connection = getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const batchPk = randomUUID();
|
||||
|
||||
const run = connection.transaction(() => {
|
||||
connection
|
||||
.prepare(
|
||||
`
|
||||
DELETE FROM alert_authorization_jwts
|
||||
WHERE user_id = ? AND status = ?
|
||||
`
|
||||
)
|
||||
.run(input.userId, ALERT_JWT_STATUS_UNUSED);
|
||||
|
||||
connection
|
||||
.prepare(
|
||||
`
|
||||
DELETE FROM alert_authorization_batches
|
||||
WHERE user_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT DISTINCT batch_pk FROM alert_authorization_jwts
|
||||
WHERE user_id = ?
|
||||
)
|
||||
`
|
||||
)
|
||||
.run(input.userId, input.userId);
|
||||
|
||||
connection
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO alert_authorization_batches (
|
||||
id, user_id, batch_id, timezone, created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(batchPk, input.userId, input.batchId, input.timezone, now);
|
||||
|
||||
const insertJwt = connection.prepare(
|
||||
`
|
||||
INSERT INTO alert_authorization_jwts (
|
||||
id, batch_pk, user_id, batch_id, sequence, day, jwt,
|
||||
nbf, exp, status, consumed_at, created_at
|
||||
) VALUES (
|
||||
@id, @batch_pk, @user_id, @batch_id, @sequence, @day, @jwt,
|
||||
@nbf, @exp, @status, @consumed_at, @created_at
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
for (const item of input.jwts) {
|
||||
insertJwt.run({
|
||||
id: randomUUID(),
|
||||
batch_pk: batchPk,
|
||||
user_id: input.userId,
|
||||
batch_id: input.batchId,
|
||||
sequence: item.sequence,
|
||||
day: item.day,
|
||||
jwt: item.jwt,
|
||||
nbf: item.nbf,
|
||||
exp: item.exp,
|
||||
status: ALERT_JWT_STATUS_UNUSED,
|
||||
consumed_at: null,
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
run();
|
||||
|
||||
const unusedCount = await this.countUnused(input.userId);
|
||||
return {
|
||||
batch: {
|
||||
id: batchPk,
|
||||
userId: input.userId,
|
||||
batchId: input.batchId,
|
||||
timezone: input.timezone,
|
||||
createdAt: now,
|
||||
},
|
||||
storedCount: input.jwts.length,
|
||||
unusedCount,
|
||||
};
|
||||
},
|
||||
|
||||
async getUnusedForDay(
|
||||
userId: string,
|
||||
day: string
|
||||
): Promise<AlertAuthorizationJwtRecord | undefined> {
|
||||
const row = getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
SELECT ${JWT_COLUMNS} FROM alert_authorization_jwts
|
||||
WHERE user_id = ? AND day = ? AND status = ?
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(userId, day, ALERT_JWT_STATUS_UNUSED) as JwtDbRow | undefined;
|
||||
return row === undefined ? undefined : toJwtRecord(row);
|
||||
},
|
||||
|
||||
async countUnused(userId: string): Promise<number> {
|
||||
const row = getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) AS n FROM alert_authorization_jwts
|
||||
WHERE user_id = ? AND status = ?
|
||||
`
|
||||
)
|
||||
.get(userId, ALERT_JWT_STATUS_UNUSED) as { n: number };
|
||||
return row.n;
|
||||
},
|
||||
|
||||
async getLatestBatch(
|
||||
userId: string
|
||||
): Promise<AlertAuthorizationBatchRecord | undefined> {
|
||||
const row = getDatabase()
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, user_id, batch_id, timezone, created_at
|
||||
FROM alert_authorization_batches
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(userId) as BatchDbRow | undefined;
|
||||
return row === undefined ? undefined : toBatchRecord(row);
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,50 @@ CREATE INDEX IF NOT EXISTS idx_fcm_registrations_fcm_token
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token
|
||||
ON fcm_registrations (user_id, fcm_token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_authorization_batches (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
batch_id TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_auth_batches_user_id
|
||||
ON alert_authorization_batches (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_auth_batches_user_batch
|
||||
ON alert_authorization_batches (user_id, batch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_authorization_jwts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
batch_pk TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
batch_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
jwt TEXT NOT NULL,
|
||||
nbf INTEGER NOT NULL,
|
||||
exp INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_auth_jwts_batch_seq
|
||||
ON alert_authorization_jwts (batch_pk, sequence);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_day_unused
|
||||
ON alert_authorization_jwts (user_id, day) WHERE status = 'unused';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_status
|
||||
ON alert_authorization_jwts (user_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_day
|
||||
ON alert_authorization_jwts (user_id, day);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_batch_pk
|
||||
ON alert_authorization_jwts (batch_pk);
|
||||
`;
|
||||
|
||||
let database: Database.Database | null = null;
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@ const port = Number(process.env.PORT) || 3003;
|
||||
app.use(
|
||||
cors({
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
methods: ["GET", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
// Keep stable for diagnostics tooling compatibility
|
||||
app.get("/health", (_req, res) => {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import express, { Router } from "express";
|
||||
import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js";
|
||||
import { db } from "../db/fcmTokensSqlite.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireAuthOrNotificationLocalTest,
|
||||
requireEndorserAuth,
|
||||
} from "../middleware/auth.js";
|
||||
import {
|
||||
type AlertAuthorizationRequestBody,
|
||||
unsupportedIdentityFailure,
|
||||
validateAlertAuthorizationBatch,
|
||||
} from "../services/alertAuthorization.js";
|
||||
import { identitySupportsDelegatedJwtBatch } from "../vc/index.js";
|
||||
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||
import { maskToken } from "../util/maskToken.js";
|
||||
|
||||
@@ -88,6 +96,88 @@ notificationsRouter.post(
|
||||
}
|
||||
);
|
||||
|
||||
notificationsRouter.put(
|
||||
"/alert-authorization",
|
||||
requireAuth,
|
||||
requireEndorserAuth,
|
||||
async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[AlertAuthorization] Request received, user=" + userId);
|
||||
|
||||
if (!identitySupportsDelegatedJwtBatch(userId)) {
|
||||
const failure = unsupportedIdentityFailure(userId);
|
||||
console.log(
|
||||
"[AlertAuthorization] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
failure.error
|
||||
);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: failure.error,
|
||||
message: failure.message,
|
||||
details: failure.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (req.body ?? {}) as AlertAuthorizationRequestBody;
|
||||
const validated = await validateAlertAuthorizationBatch(userId, body);
|
||||
if (!validated.ok) {
|
||||
console.log(
|
||||
"[AlertAuthorization] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
validated.error + ",",
|
||||
validated.details[0] ?? validated.message
|
||||
);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: validated.error,
|
||||
message: validated.message,
|
||||
details: validated.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = await alertAuthorizationDb.replaceUnusedBatch({
|
||||
userId,
|
||||
batchId: validated.batchId,
|
||||
timezone: validated.timezone,
|
||||
jwts: validated.jwts,
|
||||
});
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
batchId: stored.batch.batchId,
|
||||
timezone: stored.batch.timezone,
|
||||
storedCount: stored.storedCount,
|
||||
unusedCount: stored.unusedCount,
|
||||
});
|
||||
console.log(
|
||||
"[AlertAuthorization] Completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"batchId=" + stored.batch.batchId + ",",
|
||||
"stored=" + stored.storedCount
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[AlertAuthorization] Failed in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
errorMessage(err)
|
||||
);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Failed to store delegated notification-JWT batch.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
notificationsRouter.post(
|
||||
"/register",
|
||||
requireAuthOrNotificationLocalTest,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { EXPECTED_ALERT_JWT_BATCH_SIZE } from "../db/alertAuthorizationSqlite.js";
|
||||
import {
|
||||
DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
|
||||
decodeAndVerifyDelegatedJwt,
|
||||
identitySupportsDelegatedJwtBatch,
|
||||
} from "../vc/index.js";
|
||||
|
||||
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MAX_REPORTED_ERRORS = 20;
|
||||
|
||||
export type AlertAuthorizationRequestBody = {
|
||||
batchId?: unknown;
|
||||
timezone?: unknown;
|
||||
jwts?: unknown;
|
||||
};
|
||||
|
||||
export type ValidatedAlertJwt = {
|
||||
sequence: number;
|
||||
day: string;
|
||||
jwt: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type BatchValidationSuccess = {
|
||||
ok: true;
|
||||
batchId: string;
|
||||
timezone: string;
|
||||
jwts: ValidatedAlertJwt[];
|
||||
};
|
||||
|
||||
export type BatchValidationFailure = {
|
||||
ok: false;
|
||||
error: string;
|
||||
message: string;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
type JwtItemBody = {
|
||||
sequence?: unknown;
|
||||
day?: unknown;
|
||||
jwt?: unknown;
|
||||
nbf?: unknown;
|
||||
exp?: unknown;
|
||||
};
|
||||
|
||||
function isValidIanaTimeZone(timezone: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function calendarDayInTimeZone(epochSec: number, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date(epochSec * 1000));
|
||||
const year = parts.find((p) => p.type === "year")?.value;
|
||||
const month = parts.find((p) => p.type === "month")?.value;
|
||||
const day = parts.find((p) => p.type === "day")?.value;
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function clientErrorInfo(err: unknown): { message: string; code?: string } {
|
||||
if (err && typeof err === "object" && "clientError" in err) {
|
||||
const clientError = (err as { clientError?: { message?: string; code?: string } })
|
||||
.clientError;
|
||||
if (typeof clientError?.message === "string" && clientError.message.length > 0) {
|
||||
return { message: clientError.message, code: clientError.code };
|
||||
}
|
||||
}
|
||||
if (err instanceof Error && err.message.length > 0) {
|
||||
return { message: err.message };
|
||||
}
|
||||
return { message: "Delegated JWT verification failed." };
|
||||
}
|
||||
|
||||
function isFiniteInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value);
|
||||
}
|
||||
|
||||
export function unsupportedIdentityFailure(did: string): BatchValidationFailure {
|
||||
return {
|
||||
ok: false,
|
||||
error: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
|
||||
message:
|
||||
"This identity type cannot mint a delegated notification-JWT batch. Passkey (did:peer) sessions require a live assertion and cannot pre-issue 100 future JWTs.",
|
||||
details: [`Authenticated DID: ${did}`],
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateAlertAuthorizationBatch(
|
||||
userDid: string,
|
||||
body: AlertAuthorizationRequestBody
|
||||
): Promise<BatchValidationSuccess | BatchValidationFailure> {
|
||||
if (!identitySupportsDelegatedJwtBatch(userDid)) {
|
||||
return unsupportedIdentityFailure(userDid);
|
||||
}
|
||||
|
||||
const details: string[] = [];
|
||||
const batchId =
|
||||
typeof body.batchId === "string" ? body.batchId.trim() : undefined;
|
||||
if (batchId === undefined || batchId.length === 0) {
|
||||
details.push("batchId is required");
|
||||
}
|
||||
|
||||
const timezone =
|
||||
typeof body.timezone === "string" ? body.timezone.trim() : undefined;
|
||||
if (timezone === undefined || timezone.length === 0) {
|
||||
details.push("timezone is required (IANA name used when minting validity windows)");
|
||||
} else if (!isValidIanaTimeZone(timezone)) {
|
||||
details.push(`timezone is not a valid IANA time zone: ${timezone}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.jwts)) {
|
||||
details.push("jwts must be an array of 100 delegated JWT entries");
|
||||
return fail(details);
|
||||
}
|
||||
|
||||
if (body.jwts.length !== EXPECTED_ALERT_JWT_BATCH_SIZE) {
|
||||
details.push(
|
||||
`jwts must contain exactly ${EXPECTED_ALERT_JWT_BATCH_SIZE} entries, got ${body.jwts.length}`
|
||||
);
|
||||
return fail(details);
|
||||
}
|
||||
|
||||
const sequences = new Set<number>();
|
||||
const days = new Set<string>();
|
||||
const validated: ValidatedAlertJwt[] = [];
|
||||
|
||||
for (let i = 0; i < body.jwts.length; i++) {
|
||||
const raw = body.jwts[i];
|
||||
const prefix = `jwts[${i}]`;
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
details.push(`${prefix} must be an object`);
|
||||
continue;
|
||||
}
|
||||
const item = raw as JwtItemBody;
|
||||
|
||||
if (!isFiniteInteger(item.sequence)) {
|
||||
details.push(`${prefix}.sequence must be an integer`);
|
||||
continue;
|
||||
}
|
||||
if (sequences.has(item.sequence)) {
|
||||
details.push(`${prefix}.sequence ${item.sequence} is duplicated`);
|
||||
} else {
|
||||
sequences.add(item.sequence);
|
||||
}
|
||||
|
||||
if (typeof item.day !== "string" || !DAY_RE.test(item.day)) {
|
||||
details.push(`${prefix}.day must be YYYY-MM-DD`);
|
||||
continue;
|
||||
}
|
||||
if (days.has(item.day)) {
|
||||
details.push(`${prefix}.day ${item.day} is duplicated`);
|
||||
} else {
|
||||
days.add(item.day);
|
||||
}
|
||||
|
||||
if (typeof item.jwt !== "string" || item.jwt.length === 0) {
|
||||
details.push(`${prefix}.jwt is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isFiniteInteger(item.nbf) || !isFiniteInteger(item.exp)) {
|
||||
details.push(`${prefix}.nbf and ${prefix}.exp must be integer unix seconds`);
|
||||
continue;
|
||||
}
|
||||
if (item.nbf >= item.exp) {
|
||||
details.push(`${prefix}.nbf must be less than ${prefix}.exp`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timezone !== undefined && isValidIanaTimeZone(timezone)) {
|
||||
const dayFromNbf = calendarDayInTimeZone(item.nbf, timezone);
|
||||
if (dayFromNbf !== item.day) {
|
||||
details.push(
|
||||
`${prefix}.day ${item.day} does not match nbf ${item.nbf} in timezone ${timezone} (${dayFromNbf})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const verified = await decodeAndVerifyDelegatedJwt(item.jwt);
|
||||
if (!verified.verified) {
|
||||
details.push(`${prefix}.jwt failed verification`);
|
||||
continue;
|
||||
}
|
||||
if (verified.issuer !== userDid) {
|
||||
details.push(
|
||||
`${prefix}.jwt iss ${verified.issuer} does not match the authenticated user`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const payloadNbf = verified.payload.nbf;
|
||||
const payloadExp = verified.payload.exp;
|
||||
if (payloadNbf !== item.nbf) {
|
||||
details.push(
|
||||
`${prefix}.nbf ${item.nbf} does not match JWT claim nbf ${String(payloadNbf)}`
|
||||
);
|
||||
}
|
||||
if (payloadExp !== item.exp) {
|
||||
details.push(
|
||||
`${prefix}.exp ${item.exp} does not match JWT claim exp ${String(payloadExp)}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof payloadNbf === "number" &&
|
||||
typeof payloadExp === "number" &&
|
||||
payloadNbf >= payloadExp
|
||||
) {
|
||||
details.push(`${prefix} JWT nbf must be less than exp`);
|
||||
}
|
||||
} catch (err) {
|
||||
const info = clientErrorInfo(err);
|
||||
if (info.code === DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE) {
|
||||
return {
|
||||
ok: false,
|
||||
error: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
|
||||
message: info.message,
|
||||
details: [`${prefix}: ${info.message}`],
|
||||
};
|
||||
}
|
||||
details.push(`${prefix}.jwt: ${info.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validated.push({
|
||||
sequence: item.sequence,
|
||||
day: item.day,
|
||||
jwt: item.jwt,
|
||||
nbf: item.nbf,
|
||||
exp: item.exp,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
details.length === 0 &&
|
||||
body.jwts.length === EXPECTED_ALERT_JWT_BATCH_SIZE &&
|
||||
sequences.size === EXPECTED_ALERT_JWT_BATCH_SIZE
|
||||
) {
|
||||
const seqList = [...sequences].sort((a, b) => a - b);
|
||||
const spanOk =
|
||||
seqList[seqList.length - 1] - seqList[0] ===
|
||||
EXPECTED_ALERT_JWT_BATCH_SIZE - 1;
|
||||
if (!spanOk) {
|
||||
details.push(
|
||||
`sequence values must be 100 consecutive integers (got ${seqList[0]}..${seqList[seqList.length - 1]})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
details.length > 0 ||
|
||||
batchId === undefined ||
|
||||
timezone === undefined ||
|
||||
validated.length !== EXPECTED_ALERT_JWT_BATCH_SIZE
|
||||
) {
|
||||
return fail(details);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
batchId,
|
||||
timezone,
|
||||
jwts: validated,
|
||||
};
|
||||
}
|
||||
|
||||
function fail(details: string[]): BatchValidationFailure {
|
||||
const reported = details.slice(0, MAX_REPORTED_ERRORS);
|
||||
if (details.length > MAX_REPORTED_ERRORS) {
|
||||
reported.push(`and ${details.length - MAX_REPORTED_ERRORS} more`);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "ALERT_AUTHORIZATION_INVALID_BATCH",
|
||||
message:
|
||||
"Delegated notification-JWT batch was rejected. Nothing was stored.",
|
||||
details: reported.length > 0 ? reported : ["Invalid batch"],
|
||||
};
|
||||
}
|
||||
+89
-1
@@ -13,6 +13,8 @@ export const ETHR_DID_PREFIX = "did:ethr:";
|
||||
export const PEER_DID_PREFIX = "did:peer:";
|
||||
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
|
||||
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
|
||||
export const DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE =
|
||||
"DELEGATED_JWT_UNSUPPORTED_IDENTITY";
|
||||
|
||||
const resolver = new Resolver({
|
||||
ethr: didEthLocalResolver,
|
||||
@@ -24,14 +26,26 @@ export type VerifiedJwt = {
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
|
||||
function parseJwtHeaderAndPayload(jwt: string): {
|
||||
header: Record<string, unknown>;
|
||||
payload: Record<string, unknown>;
|
||||
pieces: string[];
|
||||
} {
|
||||
const pieces = jwt.split(".");
|
||||
if (pieces.length < 3 || pieces[0].length === 0 || pieces[1].length === 0) {
|
||||
throw new Error("JWT must have a header, payload, and signature.");
|
||||
}
|
||||
const header = JSON.parse(
|
||||
Buffer.from(pieces[0], "base64url").toString("utf8")
|
||||
) as Record<string, unknown>;
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(pieces[1], "base64url").toString("utf8")
|
||||
) as Record<string, unknown>;
|
||||
return { header, payload, pieces };
|
||||
}
|
||||
|
||||
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
|
||||
const { header, payload, pieces } = parseJwtHeaderAndPayload(jwt);
|
||||
const issuerDid = payload.iss;
|
||||
if (!issuerDid || typeof issuerDid !== "string") {
|
||||
return Promise.reject({
|
||||
@@ -98,3 +112,77 @@ export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a pre-issued delegated notification JWT (future nbf is expected).
|
||||
* Passkey / did:peer JWANT tokens cannot be stored as standing credentials.
|
||||
*/
|
||||
export async function decodeAndVerifyDelegatedJwt(
|
||||
jwt: string
|
||||
): Promise<VerifiedJwt> {
|
||||
let header: Record<string, unknown>;
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
({ header, payload } = parseJwtHeaderAndPayload(jwt));
|
||||
} catch {
|
||||
return Promise.reject({
|
||||
clientError: {
|
||||
message: "Delegated credential is not a valid JWT.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const issuerDid = payload.iss;
|
||||
if (!issuerDid || typeof issuerDid !== "string") {
|
||||
return Promise.reject({
|
||||
clientError: {
|
||||
message: `Missing "iss" field in JWT.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (issuerDid.startsWith(PEER_DID_PREFIX) || header.typ === "JWANT") {
|
||||
return Promise.reject({
|
||||
clientError: {
|
||||
message:
|
||||
"This identity type cannot mint a delegated notification-JWT batch. Passkey (did:peer / JWANT) credentials require a live assertion and cannot be pre-issued for future days.",
|
||||
code: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
issuerDid.startsWith(ETHR_DID_PREFIX) &&
|
||||
process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE
|
||||
) {
|
||||
return { issuer: issuerDid, payload, verified: true };
|
||||
}
|
||||
|
||||
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
|
||||
try {
|
||||
const verified = await verifyJWT(jwt, {
|
||||
resolver,
|
||||
policies: { nbf: false },
|
||||
});
|
||||
return verified as VerifiedJwt;
|
||||
} catch (e) {
|
||||
return Promise.reject({
|
||||
clientError: {
|
||||
message: `JWT failed verification: ` + e,
|
||||
code: JWT_VERIFY_FAILED_CODE,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
clientError: {
|
||||
message: `Unsupported DID method ${issuerDid}`,
|
||||
code: UNSUPPORTED_DID_METHOD_CODE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function identitySupportsDelegatedJwtBatch(did: string): boolean {
|
||||
return did.startsWith(ETHR_DID_PREFIX);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user