Files
nostr-oidc-bridge/app.js.old
Your Name d0845a8323 first
2025-08-17 20:07:36 -04:00

344 lines
10 KiB
JavaScript

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 `
<!DOCTYPE html>
<html>
<head>
<title>Nostr OIDC Bridge</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 500px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 { color: #333; text-align: center; margin-bottom: 30px; }
.method { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; }
.method h3 { margin-top: 0; color: #555; }
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
button:hover { background: #0056b3; }
input, textarea {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
font-family: monospace;
}
.challenge {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
word-break: break-all;
font-family: monospace;
font-size: 14px;
}
.error { color: #dc3545; background: #f8d7da; padding: 10px; border-radius: 5px; margin: 10px 0; }
.success { color: #155724; background: #d4edda; padding: 10px; border-radius: 5px; margin: 10px 0; }
</style>
</head>
<body>
<div class="container">
<h1>🔐 Sign in with Nostr</h1>
<p>To authenticate, please sign the following challenge with your Nostr key:</p>
<div class="challenge">
<strong>Challenge:</strong><br>
Authenticating to ${challenge}
</div>
<div class="method">
<h3>🔌 Method 1: Browser Extension (Recommended)</h3>
<p>If you have a Nostr browser extension (nos2x, Alby, etc.), click below:</p>
<button onclick="signWithExtension()">Sign with Extension</button>
<div id="extension-result"></div>
</div>
<div class="method">
<h3>🔑 Method 2: Manual Signing</h3>
<p>If you don't have an extension, you can manually sign the challenge:</p>
<form method="POST">
<label>Your Public Key (hex):</label>
<input type="text" name="pubkey" placeholder="Enter your public key in hex format" required>
<label>Signature (hex):</label>
<input type="text" name="signature" placeholder="Enter the signature for the challenge" required>
<button type="submit">Verify Signature</button>
</form>
</div>
</div>
<script>
const challenge = "Authenticating to ${challenge}";
async function signWithExtension() {
const resultDiv = document.getElementById('extension-result');
try {
if (!window.nostr) {
throw new Error('No Nostr extension found. Please install a Nostr browser extension like nos2x or Alby.');
}
// Create event to sign
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: challenge,
};
// Get public key
const pubkey = await window.nostr.getPublicKey();
event.pubkey = pubkey;
// Sign the event
const signedEvent = await window.nostr.signEvent(event);
// Submit the signed event
const formData = new FormData();
formData.append('event', JSON.stringify(signedEvent));
const response = await fetch('/interaction/${uid}', {
method: 'POST',
body: formData
});
if (response.ok) {
resultDiv.innerHTML = '<div class="success">✅ Authentication successful! Redirecting...</div>';
// The OIDC provider will handle the redirect
window.location.reload();
} else {
const error = await response.json();
throw new Error(error.error || 'Authentication failed');
}
} catch (error) {
console.error('Extension signing error:', error);
resultDiv.innerHTML = \`<div class="error">❌ \${error.message}</div>\`;
}
}
</script>
</body>
</html>`;
}
// Middleware setup
app.use(express.static('public'));
app.use(oidc.callback());
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Nostr OIDC Bridge running on http://0.0.0.0:${PORT}`);
console.log(`🔗 Discovery URL: https://auth.laantungir.net/.well-known/openid-configuration`);
});