nip98: add a dedicated nip98 module, refactor auth98.ts
This commit is contained in:
parent
4c41ce8c0a
commit
61f5acc937
|
@ -1,50 +1,20 @@
|
||||||
import { type AppMiddleware } from '@/app.ts';
|
import { type AppMiddleware } from '@/app.ts';
|
||||||
import { Conf } from '@/config.ts';
|
import { HTTPException } from '@/deps.ts';
|
||||||
import { type Event, HTTPException } from '@/deps.ts';
|
import { parseAuthRequest, type ParseAuthRequestOpts } from '@/utils/nip98.ts';
|
||||||
import { decode64Schema, jsonSchema } from '@/schema.ts';
|
import { localRequest } from '@/utils/web.ts';
|
||||||
import { signedEventSchema } from '@/schemas/nostr.ts';
|
|
||||||
import { eventAge, findTag, sha256, Time } from '@/utils.ts';
|
|
||||||
|
|
||||||
const decodeEventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema);
|
|
||||||
|
|
||||||
interface Auth98Opts {
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-98 auth.
|
* NIP-98 auth.
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/98.md
|
* https://github.com/nostr-protocol/nips/blob/master/98.md
|
||||||
*/
|
*/
|
||||||
function auth98(opts: Auth98Opts = {}): AppMiddleware {
|
function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware {
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const authHeader = c.req.headers.get('authorization');
|
const req = localRequest(c);
|
||||||
const base64 = authHeader?.match(/^Nostr (.+)$/)?.[1];
|
const result = await parseAuthRequest(req, opts);
|
||||||
const { timeout = Time.minutes(1) } = opts;
|
|
||||||
|
|
||||||
const schema = decodeEventSchema
|
|
||||||
.refine((event) => event.kind === 27235)
|
|
||||||
.refine((event) => eventAge(event) < timeout)
|
|
||||||
.refine((event) => findTag(event.tags, 'method')?.[1] === c.req.method)
|
|
||||||
.refine((event) => {
|
|
||||||
const url = findTag(event.tags, 'u')?.[1];
|
|
||||||
try {
|
|
||||||
return url === Conf.local(c.req.url);
|
|
||||||
} catch (_e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.refine(async (event) => {
|
|
||||||
const body = await c.req.raw.clone().text();
|
|
||||||
if (!body) return true;
|
|
||||||
const hash = findTag(event.tags, 'payload')?.[1];
|
|
||||||
return hash === await sha256(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await schema.safeParseAsync(base64);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
c.set('pubkey', result.data.pubkey);
|
c.set('pubkey', result.data.pubkey);
|
||||||
c.set('proof', result.data as Event<27235>);
|
c.set('proof', result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
|
|
@ -67,7 +67,7 @@ async function lookupAccount(value: string): Promise<Event<0> | undefined> {
|
||||||
|
|
||||||
/** Return the event's age in milliseconds. */
|
/** Return the event's age in milliseconds. */
|
||||||
function eventAge(event: Event): number {
|
function eventAge(event: Event): number {
|
||||||
return new Date().getTime() - nostrDate(event.created_at).getTime();
|
return Date.now() - nostrDate(event.created_at).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTag(tags: string[][], name: string): string[] | undefined {
|
function findTag(tags: string[][], name: string): string[] | undefined {
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { type Event } from '@/deps.ts';
|
||||||
|
import { decode64Schema, jsonSchema } from '@/schema.ts';
|
||||||
|
import { signedEventSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { eventAge, findTag, sha256 } from '@/utils.ts';
|
||||||
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
|
/** Decode a Nostr event from a base64 encoded string. */
|
||||||
|
const decode64EventSchema = decode64Schema.pipe(jsonSchema).pipe(signedEventSchema);
|
||||||
|
|
||||||
|
interface ParseAuthRequestOpts {
|
||||||
|
/** Max event age (in ms). */
|
||||||
|
maxAge?: number;
|
||||||
|
/** Whether to validate the request body of the request with the payload of the auth event. (default: `true`) */
|
||||||
|
validatePayload?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the auth event from a Request, returning a zod SafeParse type. */
|
||||||
|
function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
||||||
|
const { maxAge = Time.minutes(1), validatePayload = true } = opts;
|
||||||
|
|
||||||
|
const header = req.headers.get('authorization');
|
||||||
|
const base64 = header?.match(/^Nostr (.+)$/)?.[1];
|
||||||
|
|
||||||
|
const schema = decode64EventSchema
|
||||||
|
.refine((event): event is Event<27235> => event.kind === 27235, 'Event must be kind 27235')
|
||||||
|
.refine((event) => eventAge(event) < maxAge, 'Event expired')
|
||||||
|
.refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method')
|
||||||
|
.refine((event) => tagValue(event, 'u') === req.url, 'Event URL does not match request URL')
|
||||||
|
.refine(validateBody, 'Event payload does not match request body');
|
||||||
|
|
||||||
|
function validateBody(event: Event<27235>) {
|
||||||
|
if (!validatePayload) return true;
|
||||||
|
return req.clone().text()
|
||||||
|
.then(sha256)
|
||||||
|
.then((hash) => hash === tagValue(event, 'payload'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema.safeParseAsync(base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the value for the first matching tag name in the event. */
|
||||||
|
function tagValue(event: Event, tagName: string): string | undefined {
|
||||||
|
return findTag(event.tags, tagName)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseAuthRequest, type ParseAuthRequestOpts };
|
|
@ -123,4 +123,20 @@ function activityJson<T, P extends string>(c: Context<any, P>, object: T) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { activityJson, createAdminEvent, createEvent, paginated, type PaginationParams, paginationSchema, parseBody };
|
/** Rewrite the URL of the request object to use the local domain. */
|
||||||
|
function localRequest(c: Context): Request {
|
||||||
|
return Object.create(c.req.raw, {
|
||||||
|
url: { value: Conf.local(c.req.url) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
activityJson,
|
||||||
|
createAdminEvent,
|
||||||
|
createEvent,
|
||||||
|
localRequest,
|
||||||
|
paginated,
|
||||||
|
type PaginationParams,
|
||||||
|
paginationSchema,
|
||||||
|
parseBody,
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue