import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify'; import { escape } from 'entities'; import { generateSecretKey } from 'nostr-tools'; import { z } from 'zod'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; import { parseBody } from '@/utils/api.ts'; import { aesEncrypt } from '@/utils/aes.ts'; import { generateToken, getTokenHash } from '@/utils/auth.ts'; const passwordGrantSchema = z.object({ grant_type: z.literal('password'), password: z.string(), }); const codeGrantSchema = z.object({ grant_type: z.literal('authorization_code'), code: z.string(), }); const credentialsGrantSchema = z.object({ grant_type: z.literal('client_credentials'), }); const nostrGrantSchema = z.object({ grant_type: z.literal('nostr_bunker'), pubkey: n.id(), relays: z.string().url().array().optional(), secret: z.string().optional(), }); const createTokenSchema = z.discriminatedUnion('grant_type', [ passwordGrantSchema, codeGrantSchema, credentialsGrantSchema, nostrGrantSchema, ]); const createTokenController: AppController = async (c) => { const body = await parseBody(c.req.raw); const result = createTokenSchema.safeParse(body); if (!result.success) { return c.json({ error: 'Invalid request', issues: result.error.issues }, 400); } switch (result.data.grant_type) { case 'nostr_bunker': return c.json({ access_token: await getToken(result.data), token_type: 'Bearer', scope: 'read write follow push', created_at: nostrNow(), }); case 'password': return c.json({ access_token: result.data.password, token_type: 'Bearer', scope: 'read write follow push', created_at: nostrNow(), }); case 'authorization_code': return c.json({ access_token: result.data.code, token_type: 'Bearer', scope: 'read write follow push', created_at: nostrNow(), }); case 'client_credentials': return c.json({ access_token: '_', token_type: 'Bearer', scope: 'read write follow push', created_at: nostrNow(), }); } }; // This endpoint only requires the token. // I don't think having the app credentials solves anything. const revokeTokenSchema = z.object({ token: z.string(), }); /** * Mastodon OAuth token revocation. * https://docs.joinmastodon.org/methods/oauth/#revoke */ const revokeTokenController: AppController = async (c) => { const body = await parseBody(c.req.raw); const result = revokeTokenSchema.safeParse(body); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 400); } const { token } = result.data; const kysely = await Storages.kysely(); const tokenHash = await getTokenHash(token as `token1${string}`); await kysely .deleteFrom('auth_tokens') .where('token_hash', '=', tokenHash) .execute(); return c.json({}); }; async function getToken( { pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, ): Promise<`token1${string}`> { const kysely = await Storages.kysely(); const { token, hash } = await generateToken(); const nip46Seckey = generateSecretKey(); const signer = new NConnectSigner({ encryption: 'nip44', pubkey: bunkerPubkey, signer: new NSecSigner(nip46Seckey), relay: await Storages.pubsub(), // TODO: Use the relays from the request. timeout: 60_000, }); await signer.connect(secret); const userPubkey = await signer.getPublicKey(); await kysely.insertInto('auth_tokens').values({ token_hash: hash, pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc: await aesEncrypt(Conf.seckey, nip46Seckey), nip46_relays: relays, created_at: new Date(), }).execute(); return token; } /** Display the OAuth form. */ const oauthController: AppController = (c) => { const encodedUri = c.req.query('redirect_uri'); if (!encodedUri) { return c.text('Missing `redirect_uri` query param.', 422); } const state = c.req.query('state'); const redirectUri = maybeDecodeUri(encodedUri); return c.html(`
Sign in with a Nostr bunker app. Please configure the app to use this relay: ${Conf.relay}
`); }; /** * If it's already a valid URL, keep it as-is. Otherwise decode it from a URI component. * This fixes compatibilty with Elk: https://github.com/elk-zone/elk/issues/2089#issuecomment-1546289725 */ function maybeDecodeUri(uri: string): string { try { new URL(uri); return uri; } catch (_e) { return decodeURIComponent(uri); } } /** Schema for FormData POSTed to the OAuthController. */ const oauthAuthorizeSchema = z.object({ bunker_uri: z.string().url().refine((v) => v.startsWith('bunker://')), redirect_uri: z.string().url(), state: z.string().optional(), }); /** 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 { bunker_uri, redirect_uri: redirectUri, state } = result.data; const bunker = new URL(bunker_uri); const token = await getToken({ pubkey: bunker.hostname, secret: bunker.searchParams.get('secret') || undefined, relays: bunker.searchParams.getAll('relay'), }); if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') { return c.text(token); } const url = addCodeToRedirectUri(redirectUri, token, state); return c.redirect(url); }; /** Append the given `code` as a query param to the `redirect_uri`. */ function addCodeToRedirectUri(redirectUri: string, code: string, state?: string): string { const url = new URL(redirectUri); const q = new URLSearchParams(); q.set('code', code); if (state) { q.set('state', state); } url.search = q.toString(); return url.toString(); } export { createTokenController, oauthAuthorizeController, oauthController, revokeTokenController };