diff --git a/src/app.ts b/src/app.ts index be11471..7f582d0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -27,7 +27,9 @@ import { requireAuth, setAuth } from './middleware/auth.ts'; interface AppEnv extends HonoEnv { Variables: { + /** Hex pubkey for the current user. If provided, the user is considered "logged in." */ pubkey?: string; + /** Hex secret key for the current user. Optional, but easiest way to use legacy Mastodon apps. */ seckey?: string; }; } diff --git a/src/controllers/api/oauth.ts b/src/controllers/api/oauth.ts index fb23c1c..c59b73f 100644 --- a/src/controllers/api/oauth.ts +++ b/src/controllers/api/oauth.ts @@ -1,4 +1,4 @@ -import { lodash, nip19, z } from '@/deps.ts'; +import { lodash, nip19, uuid62, z } from '@/deps.ts'; import { AppController } from '@/app.ts'; import { parseBody } from '@/utils.ts'; @@ -94,6 +94,7 @@ function maybeDecodeUri(uri: string): string { } } +/** Schema for FormData POSTed to the OAuthController. */ const oauthAuthorizeSchema = z.object({ pubkey: z.string().regex(/^[0-9a-f]{64}$/).optional().catch(undefined), nip19: z.string().regex(new RegExp(`^${nip19.BECH32_REGEX.source}$`)).optional().catch(undefined), @@ -107,21 +108,31 @@ const oauthAuthorizeSchema = z.object({ } }); +/** Controller the OAuth form is POSTed to. */ const oauthAuthorizeController: AppController = async (c) => { + /** FormData results in JSON. */ const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw)); if (!result.success) { return c.json(result.error, 422); } + // Parsed FormData values. const { pubkey, nip19: nip19id, redirect_uri: redirectUri } = result.data; + /** + * Normally the auth token is just an npub, which is public information. + * The sessionId helps us know that Request "B" and Request "A" came from the same person. + * Useful for sending websocket events to the correct client. + */ + const sessionId: string = uuid62.v4(); + if (pubkey) { const encoded = nip19.npubEncode(pubkey!); - const url = addCodeToRedirectUri(redirectUri, encoded); + const url = addCodeToRedirectUri(redirectUri, `${encoded}_${sessionId}`); return c.redirect(url); } else if (nip19id) { - const url = addCodeToRedirectUri(redirectUri, nip19id); + const url = addCodeToRedirectUri(redirectUri, `${nip19id}_${sessionId}`); return c.redirect(url); } diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index c6dcf64..6f85553 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,5 +1,6 @@ import { AppController } from '@/app.ts'; import { nip19 } from '@/deps.ts'; +import { TOKEN_REGEX } from '@/middleware/auth.ts'; import { signStreams } from '@/sign.ts'; const streamingController: AppController = (c) => { @@ -17,19 +18,26 @@ const streamingController: AppController = (c) => { return c.json({ error: 'Missing access token' }, 401); } - if (!nip19.BECH32_REGEX.test(token)) { + if (!(new RegExp(`^${TOKEN_REGEX.source}$`)).test(token)) { return c.json({ error: 'Invalid access token' }, 401); } const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token }); - socket.addEventListener('open', () => console.log('websocket: connection opened')); - socket.addEventListener('close', () => console.log('websocket: connection closed')); + socket.addEventListener('open', () => { + console.log('websocket: connection opened'); + // Only send signing events if the user has a session ID. + if (stream === 'user' && nostr === 'true' && new RegExp(`^${nip19.BECH32_REGEX.source}_\\w+$`).test(token)) { + signStreams.set(token, socket); + } + }); + socket.addEventListener('message', (e) => console.log('websocket message: ', e.data)); - if (stream === 'user' && nostr === 'true') { - signStreams.set(token, socket); - } + socket.addEventListener('close', () => { + signStreams.delete(token); + console.log('websocket: connection closed'); + }); return response; }; diff --git a/src/deps.ts b/src/deps.ts index e9fa5c6..00c1d16 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -32,3 +32,4 @@ import 'npm:linkify-plugin-hashtag@^4.1.0'; export { default as mime } from 'npm:mime@^3.0.0'; export { unfurl } from 'npm:unfurl.js@^6.3.2'; export { default as TTLCache } from 'npm:@isaacs/ttlcache@^1.4.0'; +export { default as uuid62 } from 'npm:uuid62@^1.0.2'; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 6526ff1..4775e52 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,12 +1,18 @@ import { AppMiddleware } from '@/app.ts'; import { getPublicKey, HTTPException, nip19 } from '@/deps.ts'; +/** The token includes a Bech32 Nostr ID (npub, nsec, etc) and an optional session ID. */ +const TOKEN_REGEX = new RegExp(`(${nip19.BECH32_REGEX.source})(?:_(\\w+))?`); +/** We only accept "Bearer" type. */ +const BEARER_REGEX = new RegExp(`^Bearer (${TOKEN_REGEX.source})$`); + /** NIP-19 auth middleware. */ const setAuth: AppMiddleware = async (c, next) => { const authHeader = c.req.headers.get('authorization'); + const match = authHeader?.match(BEARER_REGEX); - if (authHeader?.startsWith('Bearer ')) { - const bech32 = authHeader.replace(/^Bearer /, ''); + if (match) { + const [_, _token, bech32, _sessionId] = match; try { const decoded = nip19.decode(bech32!); @@ -40,4 +46,4 @@ const requireAuth: AppMiddleware = async (c, next) => { await next(); }; -export { requireAuth, setAuth }; +export { requireAuth, setAuth, TOKEN_REGEX }; diff --git a/src/utils.ts b/src/utils.ts index b78f0fe..62c2ff7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { getAuthor } from '@/client.ts'; -import { Context, getPublicKey, nip19, parseFormData } from '@/deps.ts'; +import { nip19, parseFormData } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { lookupNip05Cached } from '@/nip05.ts'; @@ -9,30 +9,6 @@ const nostrNow = () => Math.floor(new Date().getTime() / 1000); /** Pass to sort() to sort events by date. */ const eventDateComparator = (a: Event, b: Event) => b.created_at - a.created_at; -function getKeys(c: Context) { - const auth = c.req.headers.get('Authorization') || ''; - - if (auth.startsWith('Bearer ')) { - const privatekey = auth.split('Bearer ')[1]; - const pubkey = getPublicKey(privatekey); - - return { - privatekey, - pubkey, - }; - } -} - -/** Return true if the value is a bech32 string, eg for use with NIP-19. */ -function isBech32(value: unknown): value is string { - return typeof value === 'string' && nip19.BECH32_REGEX.test(value); -} - -/** Return true if the value is a Nostr pubkey, private key, or event ID. */ -function isNostrId(value: unknown): value is string { - return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); -} - /** Get pubkey from bech32 string, if applicable. */ function bech32ToPubkey(bech32: string): string | undefined { try { @@ -99,15 +75,4 @@ async function parseBody(req: Request): Promise { } } -export { - bech32ToPubkey, - eventDateComparator, - getKeys, - isBech32, - isNostrId, - lookupAccount, - type Nip05, - nostrNow, - parseBody, - parseNip05, -}; +export { bech32ToPubkey, eventDateComparator, lookupAccount, type Nip05, nostrNow, parseBody, parseNip05 };