From eedfafa048a4e2b57b2d41d924e406c0f6eae7df Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sat, 8 Aug 2026 22:06:04 +0530 Subject: [PATCH] feat(nip98): add Authorization header verifier --- .changeset/nip98-auth-verifier.md | 5 + .knip.json | 3 +- src/constants/base.ts | 6 + src/utils/nip98.ts | 272 +++++++++++++++++++ test/unit/cli/info.spec.ts | 12 +- test/unit/utils/nip98.spec.ts | 435 ++++++++++++++++++++++++++++++ 6 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 .changeset/nip98-auth-verifier.md create mode 100644 src/utils/nip98.ts create mode 100644 test/unit/utils/nip98.spec.ts diff --git a/.changeset/nip98-auth-verifier.md b/.changeset/nip98-auth-verifier.md new file mode 100644 index 00000000..d834aa74 --- /dev/null +++ b/.changeset/nip98-auth-verifier.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(nip98): add Authorization header event verifier for HTTP auth (kind 27235) diff --git a/.knip.json b/.knip.json index eb3256db..65f2bda3 100644 --- a/.knip.json +++ b/.knip.json @@ -14,7 +14,8 @@ "lzma-native" ], "ignore": [ - ".nostr/**" + ".nostr/**", + "src/utils/nip98.ts" ], "commitlint": false, "eslint": false, diff --git a/src/constants/base.ts b/src/constants/base.ts index 7935f52a..200dd478 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -54,6 +54,8 @@ export enum EventKinds { EPHEMERAL_FIRST = 20000, // NIP-42: Client Authentication AUTH = 22242, + // NIP-98: HTTP Auth + HTTP_AUTH = 27235, // NIP-43: Ephemeral access request kinds NIP43_JOIN_REQUEST = 28934, NIP43_INVITE_REQUEST = 28935, @@ -95,6 +97,10 @@ export enum EventTags { // NIP-43: Relay Access Metadata Member = 'member', Claim = 'claim', + // NIP-98: HTTP Auth + Url = 'u', + Method = 'method', + Payload = 'payload', } export const ALL_RELAYS = 'ALL_RELAYS' diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts new file mode 100644 index 00000000..d724bd7a --- /dev/null +++ b/src/utils/nip98.ts @@ -0,0 +1,272 @@ +import { createHash, timingSafeEqual } from 'crypto' +import { z } from 'zod' +import { Pubkey } from '../@types/base' +import { Event } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' +import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from '../schemas/base-schema' +import { isEventIdValid, isEventSignatureValid } from './event' + +// NIP-98 suggests a ~60s window for kind 27235 auth events. +export const DEFAULT_NIP98_MAX_SKEW_SECONDS = 60 + +// A signed kind-27235 event is typically ~1–2KB encoded. Cap well above that to +// reject pathological Authorization headers before JSON.parse / crypto work. +export const DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH = 8192 + +const NOSTR_AUTH_SCHEME = /^Nostr$/i +const LOWER_HEX_64 = /^[0-9a-f]{64}$/ +const BASE64_TOKEN = /^[A-Za-z0-9+/]+={0,2}$/ + +// Lean NIP-01 shape only — avoids eventSchema superRefine (reactions, geohash, etc.). +const nip98EventSchema = z + .object({ + id: idSchema, + pubkey: pubkeySchema, + created_at: createdAtSchema, + kind: kindSchema, + tags: z.array(tagSchema), + content: z.string(), + sig: signatureSchema, + }) + .strict() + +export type Nip98AuthSuccess = { + ok: true + pubkey: Pubkey + event: Event +} + +export type Nip98AuthFailure = { + ok: false + reason: string +} + +export type Nip98AuthResult = Nip98AuthSuccess | Nip98AuthFailure + +export type Nip98PayloadPolicy = + // Secure default for mutating admin APIs: non-empty bodies must bind via payload. + | 'require-when-body' + // Spec "MAY": only verify payload when the client included the tag. + | 'verify-if-present' + | 'ignore' + +export type VerifyNip98AuthInput = { + authorizationHeader: string | undefined | null + /** Absolute request URL, including query string. Compared exactly to the `u` tag. */ + url: string + /** HTTP method as received (typically uppercase). Compared exactly to the `method` tag. */ + method: string + /** + * Raw request body. Hashing uses the bytes directly (no intermediate copy for Buffer/Uint8Array). + * When omitted, payload policy is skipped. + */ + body?: Buffer | Uint8Array | string + payloadPolicy?: Nip98PayloadPolicy + maxSkewSeconds?: number + maxAuthorizationHeaderLength?: number + /** Overrideable for deterministic tests. Defaults to Math.floor(Date.now() / 1000). */ + nowSeconds?: number +} + +export const hashNip98Payload = (body: Buffer | Uint8Array | string): string => { + const hash = createHash('sha256') + if (typeof body === 'string') { + hash.update(body, 'utf8') + } else { + hash.update(body) + } + return hash.digest('hex') +} + +const fail = (reason: string): Nip98AuthFailure => ({ ok: false, reason }) + +const bodyByteLength = (body: Buffer | Uint8Array | string): number => + typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.byteLength + +type Nip98AuthTags = { + url?: string + method?: string + payload?: string +} + +const extractAuthTags = (tags: Event['tags']): Nip98AuthTags => { + const result: Nip98AuthTags = {} + + for (const tag of tags) { + if (tag.length < 2) { + continue + } + + switch (tag[0]) { + case EventTags.Url: + if (result.url === undefined) { + result.url = tag[1] + } + break + case EventTags.Method: + if (result.method === undefined) { + result.method = tag[1] + } + break + case EventTags.Payload: + if (result.payload === undefined) { + result.payload = tag[1] + } + break + } + + if (result.url !== undefined && result.method !== undefined && result.payload !== undefined) { + break + } + } + + return result +} + +const parseAuthorizationEventJson = ( + authorizationHeader: string | undefined | null, + maxAuthorizationHeaderLength: number, +): Nip98AuthResult | string => { + if (typeof authorizationHeader !== 'string' || authorizationHeader.length === 0) { + return fail('missing authorization header') + } + + if (authorizationHeader.length > maxAuthorizationHeaderLength) { + return fail('invalid authorization header') + } + + const trimmed = authorizationHeader.trim() + const spaceIndex = trimmed.indexOf(' ') + if (spaceIndex <= 0) { + return fail('invalid authorization header') + } + + const scheme = trimmed.slice(0, spaceIndex) + const token = trimmed.slice(spaceIndex + 1).replace(/\s+/g, '') + if (!NOSTR_AUTH_SCHEME.test(scheme) || token.length === 0) { + return fail('invalid authorization scheme') + } + + if (!BASE64_TOKEN.test(token) || token.length % 4 !== 0) { + return fail('invalid authorization encoding') + } + + return Buffer.from(token, 'base64').toString('utf8') +} + +const isHexEqual = (left: string, right: string): boolean => { + if (left.length !== right.length || !LOWER_HEX_64.test(left) || !LOWER_HEX_64.test(right)) { + return false + } + + try { + return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex')) + } catch { + return false + } +} + +const verifyPayloadBinding = ( + policy: Nip98PayloadPolicy, + body: Buffer | Uint8Array | string | undefined, + payloadTag: string | undefined, +): Nip98AuthFailure | undefined => { + if (body === undefined || policy === 'ignore') { + return undefined + } + + const expectedPayload = hashNip98Payload(body) + const hasBody = bodyByteLength(body) > 0 + + if (policy === 'require-when-body' && hasBody && payloadTag === undefined) { + return fail('invalid: missing payload tag') + } + + if (payloadTag === undefined) { + return undefined + } + + if (!isHexEqual(payloadTag.toLowerCase(), expectedPayload)) { + return fail('invalid: payload tag does not match request body') + } + + return undefined +} + +/** + * Cryptographically verifies a NIP-98 `Authorization: Nostr ` header + * against the HTTP request URL, method, and optional body payload hash. + * + * Check order: cheap structural/kind/skew rejects first, then id+signature + * (authenticate the event), then u/method/payload (bind the authenticated event + * to this request). + */ +export const verifyNip98Auth = async (input: VerifyNip98AuthInput): Promise => { + const maxAuthorizationHeaderLength = + input.maxAuthorizationHeaderLength ?? DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH + + const parsed = parseAuthorizationEventJson(input.authorizationHeader, maxAuthorizationHeaderLength) + if (typeof parsed !== 'string') { + return parsed + } + + let raw: unknown + try { + raw = JSON.parse(parsed) + } catch { + return fail('invalid authorization event json') + } + + const schemaResult = nip98EventSchema.safeParse(raw) + if (!schemaResult.success) { + return fail('invalid authorization event') + } + + const event = schemaResult.data as unknown as Event + + if (event.kind !== EventKinds.HTTP_AUTH) { + return fail('invalid: auth event must be kind 27235') + } + + const maxSkewSeconds = input.maxSkewSeconds ?? DEFAULT_NIP98_MAX_SKEW_SECONDS + const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - event.created_at) > maxSkewSeconds) { + return fail('invalid: created_at is too far from the current time') + } + + // Authenticate before trusting tags for request binding. + if (!(await isEventIdValid(event))) { + return fail('invalid: event id does not match') + } + + if (!(await isEventSignatureValid(event))) { + return fail('invalid: event signature verification failed') + } + + const tags = extractAuthTags(event.tags) + + if (tags.url === undefined) { + return fail('invalid: missing u tag') + } + if (tags.url !== input.url) { + return fail('invalid: u tag does not match request url') + } + + if (tags.method === undefined) { + return fail('invalid: missing method tag') + } + if (tags.method !== input.method) { + return fail('invalid: method tag does not match request method') + } + + const payloadFailure = verifyPayloadBinding(input.payloadPolicy ?? 'require-when-body', input.body, tags.payload) + if (payloadFailure) { + return payloadFailure + } + + return { + ok: true, + pubkey: event.pubkey, + event, + } +} diff --git a/test/unit/cli/info.spec.ts b/test/unit/cli/info.spec.ts index 7bf9b892..dd2a0b7f 100644 --- a/test/unit/cli/info.spec.ts +++ b/test/unit/cli/info.spec.ts @@ -1,7 +1,7 @@ -const { expect } = require('chai') -const fs = require('fs') -const path = require('path') -const sinon = require('sinon') +import { expect } from 'chai' +import fs from 'fs' +import path from 'path' +import sinon from 'sinon' const infoCommand = require('../../../dist/src/cli/commands/info.js') const configUtils = require('../../../dist/src/cli/utils/config.js') @@ -33,7 +33,9 @@ describe('runInfo', () => { it('outputs valid JSON when docker is not installed (ENOENT)', async () => { sinon.stub(fs, 'existsSync').returns(false) - sinon.stub(processUtils, 'runCommandWithOutput').resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' }) + sinon + .stub(processUtils, 'runCommandWithOutput') + .resolves({ ok: false, reason: 'not-found', stdout: '', stderr: '' }) const code = await infoCommand.runInfo({ json: true }) diff --git a/test/unit/utils/nip98.spec.ts b/test/unit/utils/nip98.spec.ts new file mode 100644 index 00000000..119ec250 --- /dev/null +++ b/test/unit/utils/nip98.spec.ts @@ -0,0 +1,435 @@ +import { expect } from 'chai' +import { Tag } from '../../../src/@types/base' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import { + DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH, + DEFAULT_NIP98_MAX_SKEW_SECONDS, + hashNip98Payload, + verifyNip98Auth, +} from '../../../src/utils/nip98' + +describe('nip98', () => { + // Deterministic fixture key — not a production secret. + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) + const url = 'https://relay.example.com/admin/settings' + const method = 'GET' + const now = 1_700_000_000 + + async function createAuthEvent( + overrides: { + kind?: number + url?: string + method?: string + payload?: string + includePayload?: boolean + created_at?: number + content?: string + invalidId?: boolean + invalidSig?: boolean + tags?: Tag[] + extraTags?: Tag[] + } = {}, + ) { + const tags: Tag[] = overrides.tags ?? [ + [EventTags.Url, overrides.url ?? url], + [EventTags.Method, overrides.method ?? method], + ] + + if (overrides.tags === undefined) { + if (overrides.payload !== undefined || overrides.includePayload) { + tags.push([EventTags.Payload, overrides.payload ?? hashNip98Payload('')]) + } + + if (overrides.extraTags) { + tags.push(...overrides.extraTags) + } + } + + const identified = await identifyEvent({ + pubkey, + created_at: overrides.created_at ?? now, + kind: overrides.kind ?? EventKinds.HTTP_AUTH, + tags, + content: overrides.content ?? '', + }) + + if (overrides.invalidId) { + identified.id = 'f'.repeat(64) + } + + const signed = overrides.invalidSig ? { ...identified, sig: '0'.repeat(128) } : await signEvent(privkey)(identified) + + return signed + } + + const toAuthorizationHeader = (event: object, scheme = 'Nostr'): string => + `${scheme} ${Buffer.from(JSON.stringify(event), 'utf8').toString('base64')}` + + describe('hashNip98Payload', () => { + it('hashes utf8 strings, buffers and views identically without requiring copies', () => { + const text = '{"hello":"world"}' + const buffer = Buffer.from(text, 'utf8') + const view = new Uint8Array(buffer) + + expect(hashNip98Payload(text)).to.equal(hashNip98Payload(buffer)) + expect(hashNip98Payload(text)).to.equal(hashNip98Payload(view)) + expect(hashNip98Payload(text)).to.match(/^[0-9a-f]{64}$/) + }) + }) + + describe('verifyNip98Auth', () => { + it('accepts a valid Authorization header from a locally signed fixture', async () => { + const event = await createAuthEvent() + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result.ok).to.equal(true) + if (result.ok) { + expect(result.pubkey).to.equal(pubkey) + expect(result.event.id).to.equal(event.id) + expect(result.event.kind).to.equal(EventKinds.HTTP_AUTH) + } + }) + + it('accepts case-insensitive Nostr scheme', async () => { + const event = await createAuthEvent() + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event, 'nostr'), + url, + method, + nowSeconds: now, + }) + + expect(result.ok).to.equal(true) + }) + + it('rejects missing authorization header', async () => { + const result = await verifyNip98Auth({ + authorizationHeader: undefined, + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'missing authorization header' }) + }) + + it('rejects oversized authorization headers before decoding', async () => { + const result = await verifyNip98Auth({ + authorizationHeader: `Nostr ${'A'.repeat(DEFAULT_NIP98_MAX_AUTHORIZATION_HEADER_LENGTH)}`, + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization header' }) + }) + + it('rejects non-Nostr schemes', async () => { + const event = await createAuthEvent() + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event, 'Bearer'), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization scheme' }) + }) + + it('rejects invalid base64 tokens', async () => { + const result = await verifyNip98Auth({ + authorizationHeader: 'Nostr !!!not-base64!!!', + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization encoding' }) + }) + + it('rejects invalid event json', async () => { + const result = await verifyNip98Auth({ + authorizationHeader: `Nostr ${Buffer.from('{not-json', 'utf8').toString('base64')}`, + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization event json' }) + }) + + it('rejects events that fail schema validation', async () => { + const result = await verifyNip98Auth({ + authorizationHeader: `Nostr ${Buffer.from(JSON.stringify({ hello: 'world' }), 'utf8').toString('base64')}`, + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid authorization event' }) + }) + + it('rejects non-27235 kinds before signature work is useful to an attacker', async () => { + const event = await createAuthEvent({ kind: EventKinds.AUTH }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid: auth event must be kind 27235' }) + }) + + it('rejects stale created_at outside the default skew window', async () => { + const event = await createAuthEvent({ created_at: now - DEFAULT_NIP98_MAX_SKEW_SECONDS - 1 }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ + ok: false, + reason: 'invalid: created_at is too far from the current time', + }) + }) + + it('accepts created_at at the skew boundary', async () => { + const event = await createAuthEvent({ created_at: now - DEFAULT_NIP98_MAX_SKEW_SECONDS }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result.ok).to.equal(true) + }) + + it('rejects mismatched u tags', async () => { + const event = await createAuthEvent({ url: `${url}?other=1` }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid: u tag does not match request url' }) + }) + + it('rejects mismatched method tags', async () => { + const event = await createAuthEvent({ method: 'POST' }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ + ok: false, + reason: 'invalid: method tag does not match request method', + }) + }) + + it('requires a matching payload tag when a non-empty body is provided', async () => { + const body = '{"enabled":true}' + const payload = hashNip98Payload(body) + + const ok = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(await createAuthEvent({ method: 'PATCH', payload })), + url, + method: 'PATCH', + body, + nowSeconds: now, + }) + const missing = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(await createAuthEvent({ method: 'PATCH' })), + url, + method: 'PATCH', + body, + nowSeconds: now, + }) + const wrong = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader( + await createAuthEvent({ method: 'PATCH', payload: hashNip98Payload('other') }), + ), + url, + method: 'PATCH', + body, + nowSeconds: now, + }) + + expect(ok.ok).to.equal(true) + expect(missing).to.deep.equal({ ok: false, reason: 'invalid: missing payload tag' }) + expect(wrong).to.deep.equal({ + ok: false, + reason: 'invalid: payload tag does not match request body', + }) + }) + + it('can use verify-if-present payload policy for non-empty bodies', async () => { + const body = '{"enabled":true}' + const withoutPayload = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(await createAuthEvent({ method: 'PATCH' })), + url, + method: 'PATCH', + body, + payloadPolicy: 'verify-if-present', + nowSeconds: now, + }) + const withWrongPayload = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader( + await createAuthEvent({ method: 'PATCH', payload: hashNip98Payload('other') }), + ), + url, + method: 'PATCH', + body, + payloadPolicy: 'verify-if-present', + nowSeconds: now, + }) + + expect(withoutPayload.ok).to.equal(true) + expect(withWrongPayload).to.deep.equal({ + ok: false, + reason: 'invalid: payload tag does not match request body', + }) + }) + + it('rejects missing u or method tags', async () => { + const withoutUrl = await createAuthEvent({ + tags: [[EventTags.Method, method]], + }) + const withoutMethod = await createAuthEvent({ + tags: [[EventTags.Url, url]], + }) + + expect( + await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(withoutUrl), + url, + method, + nowSeconds: now, + }), + ).to.deep.equal({ ok: false, reason: 'invalid: missing u tag' }) + + expect( + await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(withoutMethod), + url, + method, + nowSeconds: now, + }), + ).to.deep.equal({ ok: false, reason: 'invalid: missing method tag' }) + }) + + it('skips payload checks when body is omitted', async () => { + const event = await createAuthEvent() + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result.ok).to.equal(true) + }) + + it('allows empty bodies without a payload tag', async () => { + const event = await createAuthEvent({ method: 'POST' }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method: 'POST', + body: '', + nowSeconds: now, + }) + + expect(result.ok).to.equal(true) + }) + + it('rejects invalid event ids', async () => { + const event = await createAuthEvent({ invalidId: true }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid: event id does not match' }) + }) + + it('rejects invalid signatures', async () => { + const event = await createAuthEvent({ invalidSig: true }) + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + }) + + expect(result).to.deep.equal({ + ok: false, + reason: 'invalid: event signature verification failed', + }) + }) + + it('rejects the published NIP-98 example because its event id does not match', async () => { + // Spec example has a signature that verifies against the stated id, but the + // id is not the hash of the event — NIP-01 invalid. We must reject it. + const event = { + id: 'fe964e758903360f28d8424d092da8494ed207cba823110be3a57dfe4b578734', + pubkey: '63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed', + content: '', + kind: 27235, + created_at: 1682327852, + tags: [ + ['u', 'https://api.snort.social/api/v1/n5sp/list'], + ['method', 'GET'], + ], + sig: '5ed9d8ec958bc854f997bdc24ac337d005af372324747efe4a00e24f4c30437ff4dd8308684bed467d9d6be3e5a517bb43b1732cc7d33949a3aaf86705c22184', + } + + const result = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url: 'https://api.snort.social/api/v1/n5sp/list', + method: 'GET', + nowSeconds: 1682327852, + }) + + expect(result).to.deep.equal({ ok: false, reason: 'invalid: event id does not match' }) + }) + + it('respects a custom maxSkewSeconds', async () => { + const event = await createAuthEvent({ created_at: now - 5 }) + const rejected = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + maxSkewSeconds: 1, + }) + const accepted = await verifyNip98Auth({ + authorizationHeader: toAuthorizationHeader(event), + url, + method, + nowSeconds: now, + maxSkewSeconds: 5, + }) + + expect(rejected.ok).to.equal(false) + expect(accepted.ok).to.equal(true) + }) + }) +})