import express from 'express'; import { Provider } from 'oidc-provider'; import { nip19, getEventHash, getSignature, verifyEvent } from 'nostr-tools'; import crypto from 'crypto'; const app = express(); const PORT = 3001; // In-memory storage for demo purposes const users = new Map(); const sessions = new Map(); const authCodes = new Map(); // Production OIDC configuration const configuration = { clients: [{ client_id: 'gitea', client_secret: 'gitea-secret', redirect_uris: ['https://git.laantungir.net/user/oauth2/nostr/callback'], response_types: ['code'], grant_types: ['authorization_code'], scope: 'openid profile email', }], claims: { openid: ['sub'], profile: ['name', 'preferred_username'], email: ['email'], }, features: { devInteractions: { enabled: false }, introspection: { enabled: true }, revocation: { enabled: true }, }, ttl: { AccessToken: 1 * 60 * 60, // 1 hour AuthorizationCode: 10 * 60, // 10 minutes IdToken: 1 * 60 * 60, // 1 hour DeviceCode: 10 * 60, // 10 minutes RefreshToken: 1 * 24 * 60 * 60, // 1 day }, issueRefreshToken: async (ctx, token, client) => { return client.grantTypes.includes('refresh_token') && token.scopes.has('offline_access'); }, interactions: { url: async (ctx, interaction) => { return `/interaction/${interaction.uid}`; }, }, findAccount: async (ctx, sub, token) => { const user = users.get(sub); if (!user) return undefined; return { accountId: sub, claims: async (use, scope) => { return { sub: user.pubkey, name: user.name || user.pubkey.slice(0, 16), preferred_username: user.name || user.pubkey.slice(0, 16), email: user.email || `${user.pubkey.slice(0, 16)}@nostr.local`, }; }, }; }, }; // Initialize OIDC provider with production issuer const oidc = new Provider('https://auth.laantungir.net', configuration); // Custom interaction handling oidc.use(async (ctx, next) => { if (ctx.path.startsWith('/interaction/')) { const uid = ctx.path.split('/')[2]; const interaction = await oidc.interactionDetails(ctx.req, ctx.res); if (ctx.method === 'GET') { // Show login form const challenge = crypto.randomBytes(32).toString('hex'); sessions.set(uid, { challenge, interaction }); // Render login page ctx.type = 'html'; ctx.body = await renderLoginPage(challenge, uid, interaction); return; } if (ctx.method === 'POST') { // Handle login submission const body = await parseBody(ctx); const sessionData = sessions.get(uid); if (!sessionData) { ctx.status = 400; ctx.body = { error: 'Session expired' }; return; } try { let nostrEvent; if (body.signature && body.pubkey) { // Manual signing nostrEvent = { kind: 1, pubkey: body.pubkey, created_at: Math.floor(Date.now() / 1000), tags: [], content: `Authenticating to ${sessionData.challenge}`, id: '', sig: body.signature }; nostrEvent.id = getEventHash(nostrEvent); } else if (body.event) { // Extension signing nostrEvent = JSON.parse(body.event); } else { throw new Error('No signature provided'); } // Verify the event if (!verifyEvent(nostrEvent)) { throw new Error('Invalid signature'); } // Check if challenge matches if (!nostrEvent.content.includes(sessionData.challenge)) { throw new Error('Challenge mismatch'); } // Create or get user const pubkey = nostrEvent.pubkey; const npub = nip19.npubEncode(pubkey); if (!users.has(pubkey)) { users.set(pubkey, { pubkey, npub, name: npub.slice(0, 16), email: `${pubkey.slice(0, 16)}@nostr.local`, }); } const user = users.get(pubkey); // Complete the interaction const result = { login: { account: pubkey, }, }; await oidc.interactionFinished(ctx.req, ctx.res, result, { mergeWithLastSubmission: false, }); sessions.delete(uid); return; } catch (error) { console.error('Authentication error:', error); ctx.status = 400; ctx.body = { error: error.message }; return; } } } await next(); }); // Helper function to parse POST body async function parseBody(ctx) { return new Promise((resolve, reject) => { let body = ''; ctx.req.on('data', chunk => { body += chunk.toString(); }); ctx.req.on('end', () => { try { const parsed = new URLSearchParams(body); const result = {}; for (const [key, value] of parsed) { result[key] = value; } resolve(result); } catch (error) { reject(error); } }); }); } // Helper function to render login page async function renderLoginPage(challenge, uid, interaction) { return `
To authenticate, please sign the following challenge with your Nostr key:
If you have a Nostr browser extension (nos2x, Alby, etc.), click below:
If you don't have an extension, you can manually sign the challenge: