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:
Jose Olarte III
2026-08-26 20:40:52 +08:00
parent 9c7c7fa271
commit 00cd0cf8f4
10 changed files with 791 additions and 7 deletions
Binary file not shown.
+5
View File
@@ -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`
+38 -3
View File
@@ -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
View File
@@ -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",
+235
View File
@@ -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);
},
};
+44
View File
@@ -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
View File
@@ -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) => {
+90
View File
@@ -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,
+287
View File
@@ -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
View File
@@ -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);
}