From d8ddfd2e004884af15e5f2b24de0c5aa860c3cce Mon Sep 17 00:00:00 2001 From: m0wer Date: Fri, 27 Jun 2025 21:43:36 +0200 Subject: [PATCH 1/7] feat: add OAuth support for external applications --- api/ssrApollo.js | 23 +- lib/oauth-auth.js | 211 ++++++++ lib/oauth-scopes.js | 256 ++++++++++ lib/rate-limit.js | 26 + package-lock.json | 10 + package.json | 1 + pages/_app.js | 9 +- pages/api/oauth/applications.js | 171 +++++++ pages/api/oauth/applications/[id].js | 258 ++++++++++ pages/api/oauth/authorize.js | 248 ++++++++++ pages/api/oauth/token.js | 280 +++++++++++ pages/api/oauth/wallet/balance.js | 43 ++ pages/api/oauth/wallet/invoices.js | 206 ++++++++ pages/api/oauth/wallet/send.js | 121 +++++ pages/oauth/consent.js | 301 ++++++++++++ pages/settings/index.js | 5 + pages/settings/oauth-applications.js | 460 ++++++++++++++++++ .../20250624_oauth_support/migration.sql | 296 +++++++++++ prisma/schema.prisma | 209 ++++++++ public/oauth-service-worker.js | 254 ++++++++++ 20 files changed, 3382 insertions(+), 6 deletions(-) create mode 100644 lib/oauth-auth.js create mode 100644 lib/oauth-scopes.js create mode 100644 lib/rate-limit.js create mode 100644 pages/api/oauth/applications.js create mode 100644 pages/api/oauth/applications/[id].js create mode 100644 pages/api/oauth/authorize.js create mode 100644 pages/api/oauth/token.js create mode 100644 pages/api/oauth/wallet/balance.js create mode 100644 pages/api/oauth/wallet/invoices.js create mode 100644 pages/api/oauth/wallet/send.js create mode 100644 pages/oauth/consent.js create mode 100644 pages/settings/oauth-applications.js create mode 100644 prisma/migrations/20250624_oauth_support/migration.sql create mode 100644 public/oauth-service-worker.js diff --git a/api/ssrApollo.js b/api/ssrApollo.js index d5513b7d9..6492c2138 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -139,9 +139,10 @@ function oneDayReferral (request, { me }) { * @param opts.notFound function that tests data to determine if 404 * @param opts.authRequired boolean that determines if auth is required */ -export function getGetServerSideProps ( - { query: queryOrFunc, variables: varsOrFunc, notFound, authRequired }) { - return async function ({ req, res, query: params }) { +export function getGetServerSideProps (options, pageGetServerSideProps) { + const { query: queryOrFunc, variables: varsOrFunc, notFound, authRequired } = options + return async function (context) { + const { req, res, query: params } = context const { nodata, ...realParams } = params // we want to use client-side cache if (nodata) return { props: { } } @@ -211,11 +212,27 @@ export function getGetServerSideProps ( } } + let pageProps = {} + if (pageGetServerSideProps) { + const pageResult = await pageGetServerSideProps(context) + + if (pageResult.notFound) { + return { notFound: true } + } + + if (pageResult.redirect) { + return { redirect: pageResult.redirect } + } + + pageProps = pageResult.props + } + oneDayReferral(req, { me }) return { props: { ...props, + ...pageProps, me, price, blockHeight, diff --git a/lib/oauth-auth.js b/lib/oauth-auth.js new file mode 100644 index 000000000..a6819e072 --- /dev/null +++ b/lib/oauth-auth.js @@ -0,0 +1,211 @@ +import models from '../api/models' + +export async function authenticateOAuth (req, requiredScopes = []) { + const authHeader = req.headers.authorization + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return { + success: false, + error: 'Missing or invalid authorization header' + } + } + + const token = authHeader.substring(7) // Remove 'Bearer ' prefix + + try { + // Find the access token + const accessToken = await models.oAuthAccessToken.findFirst({ + where: { + token, + revoked: false, + expiresAt: { + gt: new Date() + } + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + emailHash: true + } + }, + application: { + select: { + id: true, + name: true, + suspended: true, + rateLimitRpm: true, + rateLimitDaily: true + } + } + } + }) + + if (!accessToken) { + return { + success: false, + error: 'Invalid or expired access token' + } + } + + if (accessToken.application.suspended) { + return { + success: false, + error: 'Application is suspended' + } + } + + // Check rate limits + const rateLimitResult = await checkRateLimit(accessToken.applicationId, accessToken.userId) + if (!rateLimitResult.allowed) { + return { + success: false, + error: rateLimitResult.reason, + retryAfter: rateLimitResult.retryAfter + } + } + + // Check if the token has the required scopes + const tokenScopes = accessToken.scopes.map(s => s.replace('_', ':')) + + for (const requiredScope of requiredScopes) { + if (!tokenScopes.includes(requiredScope)) { + return { + success: false, + error: `Insufficient scope. Required: ${requiredScopes.join(', ')}` + } + } + } + + // Update last used timestamp + await models.oAuthAccessToken.update({ + where: { id: accessToken.id }, + data: { + lastUsedAt: new Date(), + lastUsedIp: getClientIP(req) + } + }) + + // Log API usage + await logApiUsage(req, accessToken) + + return { + success: true, + user: accessToken.user, + application: accessToken.application, + accessToken: { + id: accessToken.id, + applicationId: accessToken.applicationId, + scopes: tokenScopes + }, + scopes: tokenScopes + } + } catch (error) { + console.error('OAuth authentication error:', error) + return { + success: false, + error: 'Authentication failed' + } + } +} + +export async function checkRateLimit (applicationId, userId = null) { + try { + const application = await models.oAuthApplication.findUnique({ + where: { id: applicationId }, + select: { + rateLimitRpm: true, + rateLimitDaily: true + } + }) + + if (!application) { + return { allowed: false, reason: 'Application not found' } + } + + const now = new Date() + const oneMinuteAgo = new Date(now.getTime() - 60 * 1000) + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) + + // Check RPM limit + if (application.rateLimitRpm) { + const recentRequests = await models.oAuthApiUsage.count({ + where: { + applicationId, + createdAt: { + gte: oneMinuteAgo + } + } + }) + + if (recentRequests >= application.rateLimitRpm) { + return { + allowed: false, + reason: 'Rate limit exceeded (requests per minute)', + retryAfter: 60 + } + } + } + + // Check daily limit + if (application.rateLimitDaily) { + const dailyRequests = await models.oAuthApiUsage.count({ + where: { + applicationId, + createdAt: { + gte: oneDayAgo + } + } + }) + + if (dailyRequests >= application.rateLimitDaily) { + return { + allowed: false, + reason: 'Rate limit exceeded (daily requests)', + retryAfter: 24 * 60 * 60 + } + } + } + + return { allowed: true } + } catch (error) { + console.error('Rate limit check error:', error) + return { allowed: false, reason: 'Rate limit check failed' } + } +} + +async function logApiUsage (req, accessToken) { + try { + const endpoint = req.url.split('?')[0] // Remove query parameters + const method = req.method + const userAgent = req.headers['user-agent'] + const ip = getClientIP(req) + + await models.oAuthApiUsage.create({ + data: { + applicationId: accessToken.applicationId, + accessTokenId: accessToken.id, + endpoint, + method, + statusCode: 200, // Will be updated if needed + userId: accessToken.userId, + ipAddress: ip, + userAgent + } + }) + } catch (error) { + // Don't fail the request if logging fails + console.error('API usage logging error:', error) + } +} + +function getClientIP (req) { + return req.headers['x-forwarded-for']?.split(',')[0] || + req.headers['x-real-ip'] || + req.connection?.remoteAddress || + req.socket?.remoteAddress || + req.connection?.socket?.remoteAddress || + 'unknown' +} diff --git a/lib/oauth-scopes.js b/lib/oauth-scopes.js new file mode 100644 index 000000000..f2f5e9326 --- /dev/null +++ b/lib/oauth-scopes.js @@ -0,0 +1,256 @@ +// OAuth 2.0 Scope Management System + +export const OAUTH_SCOPES = { + // Basic read access + read: { + name: 'Read Access', + description: 'Read your public profile, posts, and comments', + icon: '👁️', + category: 'basic', + riskLevel: 'low' + }, + + // Content creation + 'write:posts': { + name: 'Create Posts', + description: 'Create and edit posts on your behalf', + icon: '✍️', + category: 'content', + riskLevel: 'medium' + }, + 'write:comments': { + name: 'Create Comments', + description: 'Create and edit comments on your behalf', + icon: '💬', + category: 'content', + riskLevel: 'medium' + }, + + // Wallet access (high risk) + 'wallet:read': { + name: 'Read Wallet', + description: 'View your wallet balance and transaction history', + icon: '👀', + category: 'wallet', + riskLevel: 'medium' + }, + 'wallet:send': { + name: 'Send Payments', + description: 'Send payments from your wallet', + icon: '⚡', + category: 'wallet', + riskLevel: 'high', + requiresApproval: true + }, + 'wallet:receive': { + name: 'Receive Payments', + description: 'Create invoices and receive payments to your wallet', + icon: '📥', + category: 'wallet', + riskLevel: 'low' + }, + + // Profile management + 'profile:read': { + name: 'Read Profile', + description: 'Access your profile information and settings', + icon: '👤', + category: 'profile', + riskLevel: 'low' + }, + 'profile:write': { + name: 'Update Profile', + description: 'Update your profile information and settings', + icon: '✏️', + category: 'profile', + riskLevel: 'medium' + }, + + // Notifications + 'notifications:read': { + name: 'Read Notifications', + description: 'Read your notifications', + icon: '🔔', + category: 'notifications', + riskLevel: 'low' + }, + 'notifications:write': { + name: 'Manage Notifications', + description: 'Manage your notification settings', + icon: '⚙️', + category: 'notifications', + riskLevel: 'low' + } +} + +export const SCOPE_CATEGORIES = { + basic: { + name: 'Basic Access', + description: 'Basic read access to public information', + color: 'primary' + }, + content: { + name: 'Content Creation', + description: 'Create and manage posts and comments', + color: 'info' + }, + wallet: { + name: 'Wallet Access', + description: 'Access to wallet functions', + color: 'warning' + }, + profile: { + name: 'Profile Management', + description: 'Access to profile information', + color: 'secondary' + }, + notifications: { + name: 'Notifications', + description: 'Access to notification system', + color: 'dark' + } +} + +export function validateScopes (requestedScopes, availableScopes = null) { + const available = availableScopes || Object.keys(OAUTH_SCOPES) + const errors = [] + const validated = [] + + for (const scope of requestedScopes) { + if (!available.includes(scope)) { + errors.push(`Invalid scope: ${scope}`) + } else { + validated.push(scope) + } + } + + return { + valid: errors.length === 0, + errors, + validatedScopes: validated + } +} + +export function getScopesByCategory (scopes) { + const categorized = {} + + for (const scope of scopes) { + const scopeInfo = OAUTH_SCOPES[scope] + if (!scopeInfo) continue + + const category = scopeInfo.category + if (!categorized[category]) { + categorized[category] = [] + } + categorized[category].push({ + scope, + ...scopeInfo + }) + } + + return categorized +} + +export function getHighRiskScopes (scopes) { + return scopes.filter(scope => { + const scopeInfo = OAUTH_SCOPES[scope] + return scopeInfo && scopeInfo.riskLevel === 'high' + }) +} + +export function getScopesRequiringApproval (scopes) { + return scopes.filter(scope => { + const scopeInfo = OAUTH_SCOPES[scope] + return scopeInfo && scopeInfo.requiresApproval + }) +} + +export function checkScopePermission (userScopes, requiredScope) { + // Check if user has the exact scope + if (userScopes.includes(requiredScope)) { + return true + } + + // Check for implicit permissions (e.g., write:posts includes read) + if (requiredScope === 'read') { + // Any write scope implies read access + return userScopes.some(scope => scope.startsWith('write:')) + } + + if (requiredScope === 'profile:read') { + // profile:write implies profile:read + return userScopes.includes('profile:write') + } + + if (requiredScope === 'wallet:read') { + // wallet:send implies wallet:read + return userScopes.includes('wallet:send') + } + + return false +} + +export function minimizeScopes (scopes) { + // Remove redundant scopes based on hierarchy + let minimized = [...scopes] + + // If write:posts is present, remove read (since write implies read) + if (minimized.includes('write:posts') || minimized.includes('write:comments')) { + minimized = minimized.filter(s => s !== 'read') + } + + // If profile:write is present, remove profile:read + if (minimized.includes('profile:write')) { + minimized = minimized.filter(s => s !== 'profile:read') + } + + // If wallet:send is present, remove wallet:read + if (minimized.includes('wallet:send')) { + minimized = minimized.filter(s => s !== 'wallet:read') + } + + return minimized +} + +export function expandScopes (scopes) { + // Add implied scopes + const expanded = new Set(scopes) + + // Write permissions include read + if (scopes.some(s => s.startsWith('write:'))) { + expanded.add('read') + } + + // profile:write includes profile:read + if (scopes.includes('profile:write')) { + expanded.add('profile:read') + } + + // wallet:send includes wallet:read + if (scopes.includes('wallet:send')) { + expanded.add('wallet:read') + } + + return Array.from(expanded) +} + +export function formatScopeList (scopes) { + return scopes.map(scope => { + const info = OAUTH_SCOPES[scope] + return info ? `${info.icon} ${info.name}` : scope + }).join(', ') +} + +export function getScopeHierarchy () { + return { + read: { + implied_by: ['write:posts', 'write:comments'] + }, + 'profile:read': { + implied_by: ['profile:write'] + }, + 'wallet:read': { + implied_by: ['wallet:send'] + } + } +} diff --git a/lib/rate-limit.js b/lib/rate-limit.js new file mode 100644 index 000000000..0f6ed20f4 --- /dev/null +++ b/lib/rate-limit.js @@ -0,0 +1,26 @@ +import LRU from 'lru-cache' + +export default function rateLimit (options) { + const tokenCache = new LRU({ + max: options.max || 500, + ttl: options.windowMs || 60 * 1000 + }) + + return (req, res) => { + const token = options.keyGenerator(req, res) + const limit = options.max + + let tokenCount = tokenCache.get(token) || 0 + tokenCount += 1 + tokenCache.set(token, tokenCount) + + const currentUsage = tokenCount + const isRateLimited = currentUsage >= limit + res.setHeader('X-RateLimit-Limit', limit) + res.setHeader('X-RateLimit-Remaining', isRateLimited ? 0 : limit - currentUsage) + + if (isRateLimited) { + throw new Error('Too many requests') + } + } +} diff --git a/package-lock.json b/package-lock.json index 22c5ae194..ceef6e5d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "async-mutex": "^0.5.0", "async-retry": "^1.3.3", "aws-sdk": "^2.1691.0", + "bcryptjs": "^3.0.2", "bech32": "^2.0.0", "bolt11": "^1.4.1", "bootstrap": "^5.3.3", @@ -7174,6 +7175,15 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", diff --git a/package.json b/package.json index d27c39a56..010ba21d4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "async-mutex": "^0.5.0", "async-retry": "^1.3.3", "aws-sdk": "^2.1691.0", + "bcryptjs": "^3.0.2", "bech32": "^2.0.0", "bolt11": "^1.4.1", "bootstrap": "^5.3.3", diff --git a/pages/_app.js b/pages/_app.js index 1c2ee9b3c..e89ca57b2 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -22,6 +22,7 @@ import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { WebLnProvider } from '@/wallets/webln/client' import { WalletsProvider } from '@/wallets/index' +import { SessionProvider } from 'next-auth/react' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -41,7 +42,7 @@ function writeQuery (client, apollo, data) { } } -export default function MyApp ({ Component, pageProps: { ...props } }) { +export default function MyApp ({ Component, pageProps: { session, ...props } }) { const client = getApolloClient() const router = useRouter() @@ -124,8 +125,10 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - {!router?.query?.disablePrompt && } + + + {!router?.query?.disablePrompt && } + diff --git a/pages/api/oauth/applications.js b/pages/api/oauth/applications.js new file mode 100644 index 000000000..2039cbd96 --- /dev/null +++ b/pages/api/oauth/applications.js @@ -0,0 +1,171 @@ +import { getServerSession } from 'next-auth/next' +import { getAuthOptions } from '../auth/[...nextauth]' +import models from '../../../api/models' +import { randomBytes } from 'crypto' +import bcrypt from 'bcryptjs' +import rateLimit from '../../../lib/rate-limit' + +const limiter = rateLimit({ + keyGenerator: (request, response) => request.ip, + max: 10, + windowMs: 15 * 60 * 1000 // 15 minutes +}) + +export default async function handler (req, res) { + const session = await getServerSession(req, res, getAuthOptions(req)) + if (!session || !session.user?.id) { + return res.status(401).json({ error: 'Authentication required' }) + } + + if (req.method === 'POST') { + return await createApplication(req, res, session) + } else if (req.method === 'GET') { + return await listApplications(req, res, session) + } else { + res.setHeader('Allow', ['GET', 'POST']) + return res.status(405).json({ error: 'Method not allowed' }) + } +} + +async function createApplication (req, res, session) { + try { + await limiter(req, res) + } catch { + return res.status(429).json({ error: 'Too many requests' }) + } + + const { + name, + description, + homepageUrl, + privacyPolicyUrl, + termsOfServiceUrl, + redirectUris, + scopes, + logoUrl + } = req.body + + // Validation + if (!name || typeof name !== 'string' || name.length < 3 || name.length > 100) { + return res.status(400).json({ error: 'Application name is required and must be 3-100 characters' }) + } + + if (!redirectUris || !Array.isArray(redirectUris) || redirectUris.length === 0) { + return res.status(400).json({ error: 'At least one redirect URI is required' }) + } + + // Validate redirect URIs + for (const uri of redirectUris) { + try { + const url = new URL(uri) + if (!['http:', 'https:'].includes(url.protocol)) { + return res.status(400).json({ error: 'Redirect URIs must use HTTP or HTTPS' }) + } + } catch { + return res.status(400).json({ error: `Invalid redirect URI: ${uri}` }) + } + } + + // Validate scopes + const validScopes = [ + 'read', 'write:posts', 'write:comments', 'wallet:read', + 'wallet:send', 'wallet:receive', 'profile:read', 'profile:write', + 'notifications:read', 'notifications:write' + ] + + if (!scopes || !Array.isArray(scopes) || scopes.length === 0) { + return res.status(400).json({ error: 'At least one scope is required' }) + } + + for (const scope of scopes) { + if (!validScopes.includes(scope)) { + return res.status(400).json({ error: `Invalid scope: ${scope}` }) + } + } + + // Generate client credentials + const clientId = randomBytes(32).toString('hex') + const clientSecret = randomBytes(32).toString('hex') + const clientSecretHash = await bcrypt.hash(clientSecret, 12) + + try { + const application = await models.oAuthApplication.create({ + data: { + name, + description, + homepageUrl, + privacyPolicyUrl, + termsOfServiceUrl, + clientId, + clientSecretHash, + redirectUris, + scopes: scopes.map(s => s.replace(':', '_')), // Convert to enum format + logoUrl, + userId: parseInt(session.user.id), + isConfidential: true, + pkceRequired: true + } + }) + + return res.status(201).json({ + id: application.id, + name: application.name, + description: application.description, + homepageUrl: application.homepageUrl, + privacyPolicyUrl: application.privacyPolicyUrl, + termsOfServiceUrl: application.termsOfServiceUrl, + clientId: application.clientId, + clientSecret, // Only returned once during creation + redirectUris: application.redirectUris, + scopes: application.scopes.map(s => s.replace('_', ':')), // Convert back to API format + logoUrl: application.logoUrl, + approved: application.approved, + createdAt: application.createdAt + }) + } catch (error) { + console.error('Error creating OAuth application:', error) + return res.status(500).json({ error: 'Failed to create application' }) + } +} + +async function listApplications (req, res, session) { + try { + const applications = await models.oAuthApplication.findMany({ + where: { + userId: parseInt(session.user.id) + }, + select: { + id: true, + name: true, + description: true, + homepageUrl: true, + privacyPolicyUrl: true, + termsOfServiceUrl: true, + clientId: true, + redirectUris: true, + scopes: true, + logoUrl: true, + approved: true, + suspended: true, + suspendedReason: true, + rateLimitRpm: true, + rateLimitDaily: true, + createdAt: true, + updatedAt: true + }, + orderBy: { + createdAt: 'desc' + } + }) + + return res.status(200).json( + applications.map(app => ({ + ...app, + scopes: app.scopes.map(s => s.replace('_', ':')) // Convert back to API format + })) + ) + } catch (error) { + console.error('Error listing OAuth applications:', error) + return res.status(500).json({ error: 'Failed to list applications' }) + } +} diff --git a/pages/api/oauth/applications/[id].js b/pages/api/oauth/applications/[id].js new file mode 100644 index 000000000..c9350ee69 --- /dev/null +++ b/pages/api/oauth/applications/[id].js @@ -0,0 +1,258 @@ +import { getServerSession } from 'next-auth/next' +import { getAuthOptions } from '../../auth/[...nextauth]' +import models from '../../../../api/models' +import bcrypt from 'bcryptjs' +import { randomBytes } from 'crypto' + +export default async function handler (req, res) { + const session = await getServerSession(req, res, getAuthOptions(req)) + if (!session || !session.user?.id) { + return res.status(401).json({ error: 'Authentication required' }) + } + + const { id } = req.query + const applicationId = parseInt(id) + + if (isNaN(applicationId)) { + return res.status(400).json({ error: 'Invalid application ID' }) + } + + if (req.method === 'GET') { + return await getApplication(req, res, session, applicationId) + } else if (req.method === 'PUT') { + return await updateApplication(req, res, session, applicationId) + } else if (req.method === 'DELETE') { + return await deleteApplication(req, res, session, applicationId) + } else { + res.setHeader('Allow', ['GET', 'PUT', 'DELETE']) + return res.status(405).json({ error: 'Method not allowed' }) + } +} + +async function getApplication (req, res, session, applicationId) { + try { + const application = await models.oAuthApplication.findFirst({ + where: { + id: applicationId, + userId: parseInt(session.user.id) + }, + select: { + id: true, + name: true, + description: true, + homepageUrl: true, + privacyPolicyUrl: true, + termsOfServiceUrl: true, + clientId: true, + redirectUris: true, + scopes: true, + logoUrl: true, + approved: true, + suspended: true, + suspendedReason: true, + rateLimitRpm: true, + rateLimitDaily: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + accessTokens: { + where: { + revoked: false, + expiresAt: { + gt: new Date() + } + } + }, + authorizationGrants: { + where: { + revoked: false + } + } + } + } + } + }) + + if (!application) { + return res.status(404).json({ error: 'Application not found' }) + } + + return res.status(200).json({ + ...application, + scopes: application.scopes.map(s => s.replace('_', ':')), + activeTokens: application._count.accessTokens, + authorizedUsers: application._count.authorizationGrants + }) + } catch (error) { + console.error('Error getting OAuth application:', error) + return res.status(500).json({ error: 'Failed to get application' }) + } +} + +async function updateApplication (req, res, session, applicationId) { + const { + name, + description, + homepageUrl, + privacyPolicyUrl, + termsOfServiceUrl, + redirectUris, + scopes, + logoUrl, + resetSecret + } = req.body + + try { + const application = await models.oAuthApplication.findFirst({ + where: { + id: applicationId, + userId: parseInt(session.user.id) + } + }) + + if (!application) { + return res.status(404).json({ error: 'Application not found' }) + } + + // Validation + const updates = {} + + if (name !== undefined) { + if (typeof name !== 'string' || name.length < 3 || name.length > 100) { + return res.status(400).json({ error: 'Application name must be 3-100 characters' }) + } + updates.name = name + } + + if (description !== undefined) { + updates.description = description + } + + if (homepageUrl !== undefined) { + updates.homepageUrl = homepageUrl + } + + if (privacyPolicyUrl !== undefined) { + updates.privacyPolicyUrl = privacyPolicyUrl + } + + if (termsOfServiceUrl !== undefined) { + updates.termsOfServiceUrl = termsOfServiceUrl + } + + if (logoUrl !== undefined) { + updates.logoUrl = logoUrl + } + + if (redirectUris !== undefined) { + if (!Array.isArray(redirectUris) || redirectUris.length === 0) { + return res.status(400).json({ error: 'At least one redirect URI is required' }) + } + + // Validate redirect URIs + for (const uri of redirectUris) { + try { + const url = new URL(uri) + if (!['http:', 'https:'].includes(url.protocol)) { + return res.status(400).json({ error: 'Redirect URIs must use HTTP or HTTPS' }) + } + } catch { + return res.status(400).json({ error: `Invalid redirect URI: ${uri}` }) + } + } + updates.redirectUris = redirectUris + } + + if (scopes !== undefined) { + const validScopes = [ + 'read', 'write:posts', 'write:comments', 'wallet:read', + 'wallet:send', 'wallet:receive', 'profile:read', 'profile:write', + 'notifications:read', 'notifications:write' + ] + + if (!Array.isArray(scopes) || scopes.length === 0) { + return res.status(400).json({ error: 'At least one scope is required' }) + } + + for (const scope of scopes) { + if (!validScopes.includes(scope)) { + return res.status(400).json({ error: `Invalid scope: ${scope}` }) + } + } + updates.scopes = scopes.map(s => s.replace(':', '_')) + } + + let newClientSecret = null + if (resetSecret) { + newClientSecret = randomBytes(32).toString('hex') + updates.clientSecretHash = await bcrypt.hash(newClientSecret, 12) + } + + const updatedApplication = await models.oAuthApplication.update({ + where: { + id: applicationId + }, + data: updates, + select: { + id: true, + name: true, + description: true, + homepageUrl: true, + privacyPolicyUrl: true, + termsOfServiceUrl: true, + clientId: true, + redirectUris: true, + scopes: true, + logoUrl: true, + approved: true, + suspended: true, + suspendedReason: true, + rateLimitRpm: true, + rateLimitDaily: true, + createdAt: true, + updatedAt: true + } + }) + + const response = { + ...updatedApplication, + scopes: updatedApplication.scopes.map(s => s.replace('_', ':')) + } + + if (newClientSecret) { + response.clientSecret = newClientSecret + } + + return res.status(200).json(response) + } catch (error) { + console.error('Error updating OAuth application:', error) + return res.status(500).json({ error: 'Failed to update application' }) + } +} + +async function deleteApplication (req, res, session, applicationId) { + try { + const application = await models.oAuthApplication.findFirst({ + where: { + id: applicationId, + userId: parseInt(session.user.id) + } + }) + + if (!application) { + return res.status(404).json({ error: 'Application not found' }) + } + + await models.oAuthApplication.delete({ + where: { + id: applicationId + } + }) + + return res.status(204).send() + } catch (error) { + console.error('Error deleting OAuth application:', error) + return res.status(500).json({ error: 'Failed to delete application' }) + } +} diff --git a/pages/api/oauth/authorize.js b/pages/api/oauth/authorize.js new file mode 100644 index 000000000..555731342 --- /dev/null +++ b/pages/api/oauth/authorize.js @@ -0,0 +1,248 @@ +import { getServerSession } from 'next-auth/next' +import { getAuthOptions } from '../auth/[...nextauth]' +import models from '../../../api/models' +import { randomBytes } from 'crypto' +import { URL } from 'url' + +export default async function handler (req, res) { + if (req.method === 'GET') { + return await handleAuthorizationRequest(req, res) + } else if (req.method === 'POST') { + return await handleAuthorizationConsent(req, res) + } else { + res.setHeader('Allow', ['GET', 'POST']) + return res.status(405).json({ error: 'Method not allowed' }) + } +} + +async function handleAuthorizationRequest (req, res) { + const { + response_type: responseType, + client_id: clientId, + redirect_uri: redirectUri, + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod + } = req.query + + console.log('handleAuthorizationRequest - req.query.scope:', scope) + + // Validate required parameters + if (responseType !== 'code') { + return redirectWithError(res, redirectUri, 'unsupported_response_type', 'Only authorization code flow is supported', state) + } + + if (!clientId) { + return res.status(400).json({ error: 'client_id is required' }) + } + + if (!redirectUri) { + return res.status(400).json({ error: 'redirect_uri is required' }) + } + + if (!scope) { + return redirectWithError(res, redirectUri, 'invalid_scope', 'scope parameter is required', state) + } + + // Find the application + const application = await models.oAuthApplication.findFirst({ + where: { + clientId, + approved: true, + suspended: false + } + }) + + if (!application) { + return redirectWithError(res, redirectUri, 'invalid_client', 'Invalid or suspended client', state) + } + + // Validate redirect URI + if (!application.redirectUris.includes(redirectUri)) { + return res.status(400).json({ error: 'Invalid redirect_uri' }) + } + + // Validate scopes + const requestedScopes = scope.split(' ') + const validScopes = application.scopes.map(s => s.replace('_', ':')) + + for (const requestedScope of requestedScopes) { + if (!validScopes.includes(requestedScope)) { + return redirectWithError(res, redirectUri, 'invalid_scope', `Invalid scope: ${requestedScope}`, state) + } + } + + // Validate PKCE if required + if (application.pkceRequired) { + if (!codeChallenge) { + return redirectWithError(res, redirectUri, 'invalid_request', 'code_challenge is required', state) + } + if (!codeChallengeMethod || !['S256', 'plain'].includes(codeChallengeMethod)) { + return redirectWithError(res, redirectUri, 'invalid_request', 'invalid code_challenge_method', state) + } + } + + // Check if user is authenticated + const session = await getServerSession(req, res, getAuthOptions(req)) + if (!session || !session.user?.id) { + // Redirect to login with return URL + const loginUrl = new URL('/login', process.env.NEXTAUTH_URL || req.headers.origin) + loginUrl.searchParams.set('callbackUrl', req.url) + return res.redirect(302, loginUrl.toString()) + } + + // Check if user has already authorized this application with these scopes + const existingGrant = await models.oAuthAuthorizationGrant.findFirst({ + where: { + userId: parseInt(session.user.id), + applicationId: application.id, + revoked: false + } + }) + + const hasAllScopes = existingGrant && requestedScopes.every(scope => + existingGrant.scopes.map(s => s.replace('_', ':')).includes(scope) + ) + + if (hasAllScopes) { + // Skip consent screen, create authorization code directly + return await createAuthorizationCode( + res, + session.user.id, + application, + redirectUri, + requestedScopes, + state, + codeChallenge, + codeChallengeMethod + ) + } + + // Redirect to consent screen + const consentUrl = new URL('/oauth/consent', process.env.NEXTAUTH_URL || req.headers.origin) + consentUrl.searchParams.set('client_id', clientId) + consentUrl.searchParams.set('redirect_uri', redirectUri) + consentUrl.searchParams.set('scope', scope) + consentUrl.searchParams.set('state', state || '') + if (codeChallenge) consentUrl.searchParams.set('code_challenge', codeChallenge) + if (codeChallengeMethod) consentUrl.searchParams.set('code_challenge_method', codeChallengeMethod) + + return res.redirect(302, consentUrl.toString()) +} + +async function handleAuthorizationConsent (req, res) { + const session = await getServerSession(req, res, getAuthOptions(req)) + if (!session || !session.user?.id) { + return res.status(401).json({ error: 'Authentication required' }) + } + + const { + client_id: clientId, + redirect_uri: redirectUri, + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + approved + } = req.body + + if (!approved) { + return redirectWithError(res, redirectUri, 'access_denied', 'User denied authorization', state) + } + + // Find the application + const application = await models.oAuthApplication.findFirst({ + where: { + clientId, + approved: true, + suspended: false + } + }) + + if (!application) { + return redirectWithError(res, redirectUri, 'invalid_client', 'Invalid or suspended client', state) + } + + if (typeof scope !== 'string' || !scope) { + return res.status(400).json({ error: 'invalid_scope', error_description: 'scope parameter is required and must be a string' }) + } + + const requestedScopes = scope.split(' ') + + // Create or update authorization grant + await models.oAuthAuthorizationGrant.upsert({ + where: { + userId_applicationId: { + userId: parseInt(session.user.id), + applicationId: application.id + } + }, + update: { + scopes: requestedScopes.map(s => s.replace(':', '_')), + lastUsedAt: new Date() + }, + create: { + userId: parseInt(session.user.id), + applicationId: application.id, + scopes: requestedScopes.map(s => s.replace(':', '_')) + } + }) + + return await createAuthorizationCode( + res, + session.user.id, + application, + redirectUri, + requestedScopes, + state, + codeChallenge, + codeChallengeMethod + ) +} + +async function createAuthorizationCode ( + res, + userId, + application, + redirectUri, + scopes, + state, + codeChallenge, + codeChallengeMethod +) { + const code = randomBytes(32).toString('hex') + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes + + await models.oAuthAuthorizationCode.create({ + data: { + code, + userId: parseInt(userId), + applicationId: application.id, + redirectUri, + scopes: scopes.map(s => s.replace(':', '_')), + codeChallenge, + codeChallengeMethod, + expiresAt + } + }) + + const redirectUrl = new URL(redirectUri) + redirectUrl.searchParams.set('code', code) + if (state) redirectUrl.searchParams.set('state', state) + + return res.redirect(302, redirectUrl.toString()) +} + +function redirectWithError (res, redirectUri, error, errorDescription, state) { + if (!redirectUri) { + return res.status(400).json({ error, error_description: errorDescription }) + } + + const redirectUrl = new URL(redirectUri) + redirectUrl.searchParams.set('error', error) + redirectUrl.searchParams.set('error_description', errorDescription) + if (state) redirectUrl.searchParams.set('state', state) + + return res.redirect(302, redirectUrl.toString()) +} diff --git a/pages/api/oauth/token.js b/pages/api/oauth/token.js new file mode 100644 index 000000000..41f6d3664 --- /dev/null +++ b/pages/api/oauth/token.js @@ -0,0 +1,280 @@ +import models from '../../../api/models' +import bcrypt from 'bcryptjs' +import { randomBytes, createHash } from 'crypto' +import rateLimit from '../../../lib/rate-limit' + +const limiter = rateLimit({ + keyGenerator: (request, response) => request.ip, + max: 20, + windowMs: 15 * 60 * 1000 // 15 minutes +}) + +export default async function handler (req, res) { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']) + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + await limiter(req, res) + } catch { + return res.status(429).json({ error: 'too_many_requests', error_description: 'Too many token requests' }) + } + + const { grant_type: grantType } = req.body + + if (grantType === 'authorization_code') { + return await handleAuthorizationCodeGrant(req, res) + } else if (grantType === 'refresh_token') { + return await handleRefreshTokenGrant(req, res) + } else { + return res.status(400).json({ + error: 'unsupported_grant_type', + error_description: 'Only authorization_code and refresh_token grants are supported' + }) + } +} + +async function handleAuthorizationCodeGrant (req, res) { + const { + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + code_verifier: codeVerifier + } = req.body + + if (!code || !redirectUri || !clientId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters' + }) + } + + // Find and validate the application + const application = await models.oAuthApplication.findFirst({ + where: { + clientId, + approved: true, + suspended: false + } + }) + + if (!application) { + return res.status(401).json({ + error: 'invalid_client', + error_description: 'Invalid client credentials' + }) + } + + // Validate client secret for confidential clients + if (application.isConfidential) { + if (!clientSecret) { + return res.status(401).json({ + error: 'invalid_client', + error_description: 'Client secret is required' + }) + } + + const secretValid = await bcrypt.compare(clientSecret, application.clientSecretHash) + if (!secretValid) { + return res.status(401).json({ + error: 'invalid_client', + error_description: 'Invalid client credentials' + }) + } + } + + // Find and validate the authorization code + const authCode = await models.oAuthAuthorizationCode.findFirst({ + where: { + code, + applicationId: application.id, + used: false, + expiresAt: { + gt: new Date() + } + } + }) + + if (!authCode) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Invalid or expired authorization code' + }) + } + + // Validate redirect URI + if (authCode.redirectUri !== redirectUri) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Redirect URI mismatch' + }) + } + + // Validate PKCE + if (authCode.codeChallenge) { + if (!codeVerifier) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'code_verifier is required' + }) + } + + let challengeValid = false + if (authCode.codeChallengeMethod === 'S256') { + const hash = createHash('sha256').update(codeVerifier).digest('base64url') + challengeValid = hash === authCode.codeChallenge + } else if (authCode.codeChallengeMethod === 'plain') { + challengeValid = codeVerifier === authCode.codeChallenge + } + + if (!challengeValid) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Invalid PKCE code verifier' + }) + } + } + + // Mark authorization code as used + await models.oAuthAuthorizationCode.update({ + where: { id: authCode.id }, + data: { used: true } + }) + + // Create access token and refresh token + const accessToken = randomBytes(32).toString('hex') + const refreshToken = randomBytes(32).toString('hex') + const accessTokenExpiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000) // 2 hours + const refreshTokenExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + + const createdAccessToken = await models.oAuthAccessToken.create({ + data: { + token: accessToken, + userId: authCode.userId, + applicationId: application.id, + scopes: authCode.scopes, + expiresAt: accessTokenExpiresAt + } + }) + + await models.oAuthRefreshToken.create({ + data: { + token: refreshToken, + userId: authCode.userId, + applicationId: application.id, + accessTokenId: createdAccessToken.id, + expiresAt: refreshTokenExpiresAt + } + }) + + return res.status(200).json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 7200, // 2 hours in seconds + refresh_token: refreshToken, + scope: authCode.scopes.map(s => s.replace('_', ':')).join(' ') + }) +} + +async function handleRefreshTokenGrant (req, res) { + const { refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret } = req.body + + if (!refreshToken || !clientId) { + return res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing required parameters' + }) + } + + // Find and validate the application + const application = await models.oAuthApplication.findFirst({ + where: { + clientId, + approved: true, + suspended: false + } + }) + + if (!application) { + return res.status(401).json({ + error: 'invalid_client', + error_description: 'Invalid client credentials' + }) + } + + // Validate client secret for confidential clients + if (application.isConfidential) { + if (!clientSecret) { + return res.status(401).json({ + error: 'invalid_client', + error_description: 'Client secret is required' + }) + } + + const secretValid = await bcrypt.compare(clientSecret, application.clientSecretHash) + if (!secretValid) { + return res.status(401).json({ + error: 'invalid_client', + error_description: 'Invalid client credentials' + }) + } + } + + // Find and validate the refresh token + const refreshTokenRecord = await models.oAuthRefreshToken.findFirst({ + where: { + token: refreshToken, + applicationId: application.id, + revoked: false, + expiresAt: { + gt: new Date() + } + }, + include: { + accessToken: true + } + }) + + if (!refreshTokenRecord) { + return res.status(400).json({ + error: 'invalid_grant', + error_description: 'Invalid or expired refresh token' + }) + } + + // Revoke the old access token + await models.oAuthAccessToken.update({ + where: { id: refreshTokenRecord.accessTokenId }, + data: { revoked: true, revokedAt: new Date() } + }) + + // Create new access token + const newAccessToken = randomBytes(32).toString('hex') + const accessTokenExpiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000) // 2 hours + + const createdAccessToken = await models.oAuthAccessToken.create({ + data: { + token: newAccessToken, + userId: refreshTokenRecord.userId, + applicationId: application.id, + scopes: refreshTokenRecord.accessToken.scopes, + expiresAt: accessTokenExpiresAt + } + }) + + // Update refresh token to point to new access token + await models.oAuthRefreshToken.update({ + where: { id: refreshTokenRecord.id }, + data: { accessTokenId: createdAccessToken.id } + }) + + return res.status(200).json({ + access_token: newAccessToken, + token_type: 'Bearer', + expires_in: 7200, // 2 hours in seconds + refresh_token: refreshToken, // Refresh token stays the same + scope: refreshTokenRecord.accessToken.scopes.map(s => s.replace('_', ':')).join(' ') + }) +} diff --git a/pages/api/oauth/wallet/balance.js b/pages/api/oauth/wallet/balance.js new file mode 100644 index 000000000..3074e77af --- /dev/null +++ b/pages/api/oauth/wallet/balance.js @@ -0,0 +1,43 @@ +import { authenticateOAuth } from '../../../../lib/oauth-auth' +import models from '../../../../api/models' + +export default async function handler (req, res) { + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']) + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const auth = await authenticateOAuth(req, ['wallet:read']) + if (!auth.success) { + return res.status(401).json({ error: auth.error }) + } + + const { user } = auth + + // Get user's current balance + const userRecord = await models.user.findUnique({ + where: { id: user.id }, + select: { + msats: true, + stackedMsats: true + } + }) + + if (!userRecord) { + return res.status(404).json({ error: 'User not found' }) + } + + const response = { + balance_msats: userRecord.msats.toString(), + balance_sats: Math.floor(Number(userRecord.msats) / 1000), + stacked_msats: userRecord.stackedMsats.toString(), + stacked_sats: Math.floor(Number(userRecord.stackedMsats) / 1000) + } + + return res.status(200).json(response) + } catch (error) { + console.error('Error in OAuth wallet balance:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} diff --git a/pages/api/oauth/wallet/invoices.js b/pages/api/oauth/wallet/invoices.js new file mode 100644 index 000000000..cb0a3f892 --- /dev/null +++ b/pages/api/oauth/wallet/invoices.js @@ -0,0 +1,206 @@ +import { authenticateOAuth } from '../../../../lib/oauth-auth' +import models from '../../../../api/models' +import { InvoiceActionType } from '@prisma/client' + +export default async function handler (req, res) { + if (req.method === 'GET') { + return await getInvoices(req, res) + } else if (req.method === 'POST') { + return await createInvoice(req, res) + } else { + res.setHeader('Allow', ['GET', 'POST']) + return res.status(405).json({ error: 'Method not allowed' }) + } +} + +async function getInvoices (req, res) { + try { + const auth = await authenticateOAuth(req, ['wallet:read']) + if (!auth.success) { + return res.status(401).json({ error: auth.error }) + } + + const { user } = auth + const { limit = 50, offset = 0, status } = req.query + + const where = { + userId: user.id + } + + if (status) { + if (status === 'paid') { + where.confirmedAt = { not: null } + } else if (status === 'pending') { + where.confirmedAt = null + where.expiresAt = { gt: new Date() } + where.cancelled = false + } else if (status === 'expired') { + where.confirmedAt = null + where.expiresAt = { lte: new Date() } + where.cancelled = false + } else if (status === 'cancelled') { + where.cancelled = true + } + } + + const invoices = await models.invoice.findMany({ + where, + select: { + id: true, + hash: true, + bolt11: true, + msatsRequested: true, + msatsReceived: true, + desc: true, + confirmedAt: true, + expiresAt: true, + cancelled: true, + createdAt: true, + actionType: true + }, + orderBy: { + createdAt: 'desc' + }, + take: Math.min(parseInt(limit), 100), + skip: parseInt(offset) + }) + + const transformedInvoices = invoices.map(invoice => ({ + id: invoice.id, + hash: invoice.hash, + bolt11: invoice.bolt11, + amount_requested_msats: invoice.msatsRequested.toString(), + amount_requested_sats: Math.floor(Number(invoice.msatsRequested) / 1000), + amount_received_msats: invoice.msatsReceived?.toString() || null, + amount_received_sats: invoice.msatsReceived ? Math.floor(Number(invoice.msatsReceived) / 1000) : null, + description: invoice.desc, + status: getInvoiceStatus(invoice), + confirmed_at: invoice.confirmedAt?.toISOString() || null, + expires_at: invoice.expiresAt.toISOString(), + created_at: invoice.createdAt.toISOString(), + action_type: invoice.actionType + })) + + return res.status(200).json({ + invoices: transformedInvoices, + pagination: { + limit: parseInt(limit), + offset: parseInt(offset), + has_more: invoices.length === parseInt(limit) + } + }) + } catch (error) { + console.error('Error in OAuth wallet invoices GET:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} + +async function createInvoice (req, res) { + try { + const auth = await authenticateOAuth(req, ['wallet:receive']) + if (!auth.success) { + return res.status(401).json({ error: auth.error }) + } + + const { user, accessToken } = auth + const { amount_msats: amountMsats, amount_sats: amountSats, description, expiry_seconds: expirySeconds = 3600 } = req.body + + let requestedAmountMsats + if (amountMsats) { + requestedAmountMsats = BigInt(amountMsats) + } else if (amountSats) { + requestedAmountMsats = BigInt(amountSats) * BigInt(1000) + } else { + return res.status(400).json({ error: 'Either amount_msats or amount_sats is required' }) + } + + if (requestedAmountMsats <= 0) { + return res.status(400).json({ error: 'Amount must be positive' }) + } + + if (requestedAmountMsats > BigInt('100000000000')) { // 100M sats + return res.status(400).json({ error: 'Amount too large' }) + } + + const expiresAt = new Date(Date.now() + expirySeconds * 1000) + + // Create invoice request for approval + const invoiceRequest = await models.oAuthWalletInvoiceRequest.create({ + data: { + userId: user.id, + applicationId: accessToken.applicationId, + accessTokenId: accessToken.id, + bolt11: '', // Will be populated when approved + amountMsats: requestedAmountMsats, + description: description || 'Invoice from OAuth app', + status: 'pending', + expiresAt + } + }) + + // For now, we'll auto-approve small amounts (< 10,000 sats) + // In a production system, larger amounts should require user approval + const autoApproveThreshold = BigInt('10000000') // 10,000 sats in msats + + if (requestedAmountMsats <= autoApproveThreshold) { + // Auto-approve and create actual invoice + const invoice = await models.invoice.create({ + data: { + userId: user.id, + msatsRequested: requestedAmountMsats, + desc: description || 'Invoice via OAuth app', + actionType: InvoiceActionType.RECEIVE, + expiresAt, + // Note: In a real implementation, you'd generate the actual bolt11 here + // using the user's configured receive wallet + bolt11: `lnbc${Math.floor(Number(requestedAmountMsats) / 1000)}...` // Placeholder + } + }) + + await models.oAuthWalletInvoiceRequest.update({ + where: { id: invoiceRequest.id }, + data: { + status: 'approved', + approved: true, + approvedAt: new Date(), + invoiceId: invoice.id, + bolt11: invoice.bolt11 + } + }) + + return res.status(201).json({ + id: invoice.id, + hash: invoice.hash, + bolt11: invoice.bolt11, + amount_requested_msats: invoice.msatsRequested.toString(), + amount_requested_sats: Math.floor(Number(invoice.msatsRequested) / 1000), + description: invoice.desc, + status: 'pending', + expires_at: invoice.expiresAt.toISOString(), + created_at: invoice.createdAt.toISOString(), + request_id: invoiceRequest.id + }) + } else { + // Requires manual approval + return res.status(202).json({ + request_id: invoiceRequest.id, + status: 'pending_approval', + amount_requested_msats: requestedAmountMsats.toString(), + amount_requested_sats: Math.floor(Number(requestedAmountMsats) / 1000), + description: description || 'Invoice from OAuth app', + expires_at: expiresAt.toISOString(), + approval_url: `/oauth/approve-invoice/${invoiceRequest.id}` + }) + } + } catch (error) { + console.error('Error in OAuth wallet invoices POST:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} + +function getInvoiceStatus (invoice) { + if (invoice.cancelled) return 'cancelled' + if (invoice.confirmedAt) return 'paid' + if (new Date() > invoice.expiresAt) return 'expired' + return 'pending' +} diff --git a/pages/api/oauth/wallet/send.js b/pages/api/oauth/wallet/send.js new file mode 100644 index 000000000..52e38ad91 --- /dev/null +++ b/pages/api/oauth/wallet/send.js @@ -0,0 +1,121 @@ +import { authenticateOAuth } from '../../../../lib/oauth-auth' +import models from '../../../../api/models' +import { parsePaymentRequest } from '@/lib/bolt11' + +export default async function handler (req, res) { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']) + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const auth = await authenticateOAuth(req, ['wallet:send']) + if (!auth.success) { + return res.status(401).json({ error: auth.error }) + } + + const { user, accessToken } = auth + const { bolt11, max_fee_msats: maxFeeMsats } = req.body + + if (!bolt11) { + return res.status(400).json({ error: 'bolt11 payment request is required' }) + } + + // Parse and validate the payment request + let parsedPaymentRequest + try { + parsedPaymentRequest = parsePaymentRequest(bolt11) + } catch (error) { + return res.status(400).json({ error: 'Invalid payment request' }) + } + + const amountMsats = BigInt(parsedPaymentRequest.mtokens || 0) + + if (amountMsats <= 0) { + return res.status(400).json({ error: 'Payment request must have a valid amount' }) + } + + // Security limits for OAuth payments + const maxPaymentMsats = BigInt('1000000000') // 1M sats + if (amountMsats > maxPaymentMsats) { + return res.status(400).json({ + error: 'Payment amount exceeds maximum allowed for OAuth applications', + max_amount_msats: maxPaymentMsats.toString() + }) + } + + // Check if user has sufficient balance + const userRecord = await models.user.findUnique({ + where: { id: user.id }, + select: { msats: true } + }) + + if (!userRecord || userRecord.msats < amountMsats) { + return res.status(400).json({ error: 'Insufficient balance' }) + } + + // Create a payment request that requires user approval + const paymentRequest = await models.oAuthWalletInvoiceRequest.create({ + data: { + userId: user.id, + applicationId: accessToken.applicationId, + accessTokenId: accessToken.id, + bolt11, + amountMsats, + description: parsedPaymentRequest.description || 'OAuth app payment', + metadata: { + destination: parsedPaymentRequest.destination, + max_fee_msats: maxFeeMsats?.toString() || null, + expires_at: parsedPaymentRequest.expires_at + }, + status: 'pending_approval', + expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes to approve + } + }) + + // For small amounts, we might auto-approve (security consideration) + const autoApproveThreshold = BigInt('100000') // 100 sats in msats + + if (amountMsats <= autoApproveThreshold) { + // In a real implementation, this would actually send the payment + // For now, we'll simulate it + + await models.oAuthWalletInvoiceRequest.update({ + where: { id: paymentRequest.id }, + data: { + status: 'approved', + approved: true, + approvedAt: new Date() + } + }) + + // TODO: Implement actual payment sending using the user's configured wallet + // This would integrate with the existing wallet system + + return res.status(200).json({ + payment_id: paymentRequest.id, + status: 'approved', + amount_msats: amountMsats.toString(), + amount_sats: Math.floor(Number(amountMsats) / 1000), + destination: parsedPaymentRequest.destination, + description: parsedPaymentRequest.description, + approved_at: new Date().toISOString() + }) + } else { + // Requires manual approval + return res.status(202).json({ + payment_id: paymentRequest.id, + status: 'pending_approval', + amount_msats: amountMsats.toString(), + amount_sats: Math.floor(Number(amountMsats) / 1000), + destination: parsedPaymentRequest.destination, + description: parsedPaymentRequest.description, + expires_at: paymentRequest.expiresAt.toISOString(), + approval_url: `/oauth/approve-payment/${paymentRequest.id}` + }) + } + } catch (error) { + console.error('Error in OAuth wallet send:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} diff --git a/pages/oauth/consent.js b/pages/oauth/consent.js new file mode 100644 index 000000000..02d586b21 --- /dev/null +++ b/pages/oauth/consent.js @@ -0,0 +1,301 @@ +import { useState } from 'react' +import { getGetServerSideProps } from '../../api/ssrApollo' +import { Container, Row, Col, Card, Button, Alert } from 'react-bootstrap' +import { useSession } from 'next-auth/react' +import Layout from '../../components/layout' + +const SCOPE_DESCRIPTIONS = { + read: 'Read your public profile, posts, and comments', + 'write:posts': 'Create and edit posts on your behalf', + 'write:comments': 'Create and edit comments on your behalf', + 'wallet:read': 'View your wallet balance and transaction history', + 'wallet:send': 'Send payments from your wallet', + 'wallet:receive': 'Create invoices and receive payments to your wallet', + 'profile:read': 'Access your profile information and settings', + 'profile:write': 'Update your profile information and settings', + 'notifications:read': 'Read your notifications', + 'notifications:write': 'Manage your notification settings' +} + +const SCOPE_ICONS = { + read: '👁️', + 'write:posts': '✍️', + 'write:comments': '💬', + 'wallet:read': '👀', + 'wallet:send': '⚡', + 'wallet:receive': '📥', + 'profile:read': '👤', + 'profile:write': '✏️', + 'notifications:read': '🔔', + 'notifications:write': '⚙️' +} + +export default function OAuthConsent ({ application = {}, scopes = [], params }) { + const { data: session } = useSession() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const handleApprove = async () => { + setLoading(true) + setError('') + + try { + const response = await fetch('/api/oauth/authorize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + ...params, + scope: scopes.join(' '), + approved: true + }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Authorization failed') + } + + // The API endpoint will redirect, but handle it manually in case + const data = await response.json() + if (data.redirect_url) { + window.location.href = data.redirect_url + } + } catch (err) { + setError(err.message) + setLoading(false) + } + } + + const handleDeny = () => { + const redirectUrl = new URL(params.redirect_uri) + redirectUrl.searchParams.set('error', 'access_denied') + redirectUrl.searchParams.set('error_description', 'User denied authorization') + if (params.state) redirectUrl.searchParams.set('state', params.state) + + window.location.href = redirectUrl.toString() + } + + console.log('Scopes received:', scopes) + const walletScopes = scopes.filter(scope => scope.startsWith('wallet:')) + const otherScopes = scopes.filter(scope => !scope.startsWith('wallet:')) + + return ( + + + + + + +

Authorize Application

+
+ + {error && ( + + {error} + + )} + +
+ {application?.logoUrl + ? ( + {`${application.name} + ) + : ( +
+ 🔗 +
+ )} +
{application?.name}
+ {application?.description && ( +

{application.description}

+ )} +
+ +
+

+ {application.name} would like to: +

+ + {otherScopes.length > 0 && ( +
+
General Permissions
+
    + {otherScopes.map(scope => ( +
  • + {SCOPE_ICONS[scope]} + {SCOPE_DESCRIPTIONS[scope]} +
  • + ))} +
+
+ )} + + {walletScopes.length > 0 && ( +
+
⚠️ Wallet Permissions
+ + Important: This application is requesting access to your wallet. + Only approve if you trust this application. + +
    + {walletScopes.map(scope => ( +
  • + {SCOPE_ICONS[scope]} + {SCOPE_DESCRIPTIONS[scope]} +
  • + ))} +
+
+ )} +
+ +
+ + By authorizing this application, you allow it to access your Stacker News account + according to the permissions listed above. You can revoke this access at any time + from your account settings. + +
+ + {application.privacyPolicyUrl && ( +
+ + Privacy Policy + + {application.termsOfServiceUrl && ( + <> + + + Terms of Service + + + )} +
+ )} + +
+ + +
+ +
+ + Signed in as @{session?.user?.name} + +
+
+
+ +
+
+
+ ) +} + +export const getServerSideProps = getGetServerSideProps({ + authRequired: true +}, async (context) => { + const { query } = context + + const clientId = query.client_id + const redirectUri = query.redirect_uri + const scope = query.scope + const state = query.state + const codeChallenge = query.code_challenge + const codeChallengeMethod = query.code_challenge_method + + if (!clientId || !redirectUri || !scope) { + return { + notFound: true + } + } + + try { + // Import models in getServerSideProps + const models = (await import('../../api/models')).default + + // Find the application + const application = await models.oAuthApplication.findFirst({ + where: { + clientId, + approved: true, + suspended: false + }, + select: { + id: true, + name: true, + description: true, + logoUrl: true, + privacyPolicyUrl: true, + termsOfServiceUrl: true, + scopes: true + } + }) + + if (!application) { + return { + notFound: true + } + } + + const requestedScopes = scope.split(' ') + console.log('getServerSideProps - query.scope:', scope) + console.log('getServerSideProps - requestedScopes:', requestedScopes) + const validScopes = application.scopes.map(s => s.replace('_', ':')) + + // Validate scopes + for (const requestedScope of requestedScopes) { + if (!validScopes.includes(requestedScope)) { + return { + notFound: true + } + } + } + + return { + props: { + application: { + ...application, + scopes: application.scopes.map(s => s.replace('_', ':')) + }, + scopes: requestedScopes, + params: { + client_id: clientId, + redirect_uri: redirectUri, + scope, + state: state || '', + code_challenge: codeChallenge || '', + code_challenge_method: codeChallengeMethod || '' + } + } + } + } catch (error) { + console.error('Error in OAuth consent getServerSideProps:', error) + return { + notFound: true + } + } +}) diff --git a/pages/settings/index.js b/pages/settings/index.js index 5ad2f813c..f2fe96ec3 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -82,6 +82,11 @@ export function SettingsHeader () { device sync + + + oauth apps + + ) diff --git a/pages/settings/oauth-applications.js b/pages/settings/oauth-applications.js new file mode 100644 index 000000000..503ec1d18 --- /dev/null +++ b/pages/settings/oauth-applications.js @@ -0,0 +1,460 @@ +import { useState, useEffect } from 'react' +import { Container, Row, Col, Card, Button, Table, Badge, Modal, Form, Alert, Spinner } from 'react-bootstrap' +import Layout from '../../components/layout' +import { formatDistanceToNow } from 'date-fns' +import { getGetServerSideProps } from '../../api/ssrApollo' +import { SettingsHeader } from './index' +import { useMe } from '../../components/me' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function OAuthApplications () { + const { me } = useMe() + const [applications, setApplications] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showModal, setShowModal] = useState(false) + const [editingApp, setEditingApp] = useState(null) + const [formData, setFormData] = useState({ + name: '', + description: '', + homepageUrl: '', + privacyPolicyUrl: '', + termsOfServiceUrl: '', + redirectUris: [''], + scopes: ['read'], + logoUrl: '' + }) + + const availableScopes = [ + 'read', + 'write:posts', + 'write:comments', + 'wallet:read', + 'wallet:send', + 'wallet:receive', + 'profile:read', + 'profile:write', + 'notifications:read', + 'notifications:write' + ] + + useEffect(() => { + if (me) { + fetchApplications() + } + }, [me]) + + const fetchApplications = async () => { + try { + const response = await fetch('/api/oauth/applications') + if (!response.ok) throw new Error('Failed to fetch applications') + const data = await response.json() + setApplications(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + try { + const url = editingApp + ? `/api/oauth/applications/${editingApp.id}` + : '/api/oauth/applications' + + const method = editingApp ? 'PUT' : 'POST' + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...formData, + redirectUris: formData.redirectUris.filter(uri => uri.trim()) + }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to save application') + } + + await fetchApplications() + setShowModal(false) + resetForm() + } catch (err) { + setError(err.message) + } + } + + const handleDelete = async (id) => { + if (!window.confirm('Are you sure you want to delete this application? This action cannot be undone.')) { + return + } + + try { + const response = await fetch(`/api/oauth/applications/${id}`, { + method: 'DELETE' + }) + + if (!response.ok) throw new Error('Failed to delete application') + + await fetchApplications() + } catch (err) { + setError(err.message) + } + } + + const handleEdit = (app) => { + setEditingApp(app) + setFormData({ + name: app.name, + description: app.description || '', + homepageUrl: app.homepageUrl || '', + privacyPolicyUrl: app.privacyPolicyUrl || '', + termsOfServiceUrl: app.termsOfServiceUrl || '', + redirectUris: app.redirectUris, + scopes: app.scopes, + logoUrl: app.logoUrl || '' + }) + setShowModal(true) + } + + const resetForm = () => { + setEditingApp(null) + setFormData({ + name: '', + description: '', + homepageUrl: '', + privacyPolicyUrl: '', + termsOfServiceUrl: '', + redirectUris: [''], + scopes: ['read'], + logoUrl: '' + }) + } + + const addRedirectUri = () => { + setFormData(prev => ({ + ...prev, + redirectUris: [...prev.redirectUris, ''] + })) + } + + const updateRedirectUri = (index, value) => { + setFormData(prev => ({ + ...prev, + redirectUris: prev.redirectUris.map((uri, i) => i === index ? value : uri) + })) + } + + const removeRedirectUri = (index) => { + setFormData(prev => ({ + ...prev, + redirectUris: prev.redirectUris.filter((_, i) => i !== index) + })) + } + + const getScopeVariant = (scope) => { + if (scope.startsWith('wallet:')) return 'warning' + if (scope.startsWith('write:')) return 'info' + return 'secondary' + } + + if (loading) { + return ( + + +
+ +
+
+
+ ) + } + + return ( + +
+ + + + +
+

OAuth Applications

+ +
+ + {error && {error}} + + {applications.length === 0 + ? ( + + +
No OAuth Applications
+

+ Create your first OAuth application to start integrating with Stacker News. +

+ +
+
+ ) + : ( + + + + + + + + + + + + + + {applications.map(app => ( + + + + + + + + + ))} + +
ApplicationClient IDScopesStatusCreatedActions
+
+ {app.logoUrl + ? ( + {app.name} + ) + : ( +
+ 🔗 +
+ )} +
+
{app.name}
+ {app.description && ( + {app.description} + )} +
+
+
+ {app.clientId} + +
+ {app.scopes.slice(0, 3).map(scope => ( + + {scope} + + ))} + {app.scopes.length > 3 && ( + + +{app.scopes.length - 3} more + + )} +
+
+ {app.approved + ? ( + Approved + ) + : ( + Pending + )} + {app.suspended && ( + Suspended + )} + + + {formatDistanceToNow(new Date(app.createdAt), { addSuffix: true })} + + + + +
+
+ )} + +
+ + setShowModal(false)} size='lg'> + + + {editingApp ? 'Edit Application' : 'Create Application'} + + +
+ + {error && {error}} + + + + + Application Name * + setFormData(prev => ({ ...prev, name: e.target.value }))} + required + /> + + + + + Logo URL + setFormData(prev => ({ ...prev, logoUrl: e.target.value }))} + /> + + + + + + Description + setFormData(prev => ({ ...prev, description: e.target.value }))} + /> + + + + + + Homepage URL + setFormData(prev => ({ ...prev, homepageUrl: e.target.value }))} + /> + + + + + Privacy Policy URL + setFormData(prev => ({ ...prev, privacyPolicyUrl: e.target.value }))} + /> + + + + + Terms of Service URL + setFormData(prev => ({ ...prev, termsOfServiceUrl: e.target.value }))} + /> + + + + + + Redirect URIs * + {formData.redirectUris.map((uri, index) => ( +
+ updateRedirectUri(index, e.target.value)} + placeholder='https://your-app.com/oauth/callback' + required={index === 0} + /> + {formData.redirectUris.length > 1 && ( + + )} +
+ ))} + +
+ + + Scopes * + {availableScopes.map(scope => ( + { + if (e.target.checked) { + setFormData(prev => ({ + ...prev, + scopes: [...prev.scopes, scope] + })) + } else { + setFormData(prev => ({ + ...prev, + scopes: prev.scopes.filter(s => s !== scope) + })) + } + }} + /> + ))} + +
+ + + + +
+
+
+
+
+ ) +} diff --git a/prisma/migrations/20250624_oauth_support/migration.sql b/prisma/migrations/20250624_oauth_support/migration.sql new file mode 100644 index 000000000..239191510 --- /dev/null +++ b/prisma/migrations/20250624_oauth_support/migration.sql @@ -0,0 +1,296 @@ +-- CreateEnum +CREATE TYPE "OAuthGrantType" AS ENUM ('authorization_code', 'refresh_token', 'client_credentials'); + +-- CreateEnum +CREATE TYPE "OAuthScope" AS ENUM ( + 'read', + 'write:posts', + 'write:comments', + 'wallet:read', + 'wallet:send', + 'wallet:receive', + 'profile:read', + 'profile:write', + 'notifications:read', + 'notifications:write' +); + +-- CreateEnum +CREATE TYPE "OAuthTokenType" AS ENUM ('bearer'); + +-- CreateTable +CREATE TABLE "OAuthApplication" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "description" TEXT, + "homepage_url" TEXT, + "privacy_policy_url" TEXT, + "terms_of_service_url" TEXT, + "client_id" TEXT NOT NULL, + "client_secret_hash" TEXT NOT NULL, + "redirect_uris" TEXT[], + "scopes" "OAuthScope"[], + "logo_url" TEXT, + "is_confidential" BOOLEAN NOT NULL DEFAULT true, + "pkce_required" BOOLEAN NOT NULL DEFAULT true, + "user_id" INTEGER NOT NULL, + "approved" BOOLEAN NOT NULL DEFAULT false, + "suspended" BOOLEAN NOT NULL DEFAULT false, + "suspended_reason" TEXT, + "rate_limit_rpm" INTEGER DEFAULT 100, + "rate_limit_daily" INTEGER DEFAULT 5000, + + CONSTRAINT "OAuthApplication_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAuthorizationCode" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "code" TEXT NOT NULL, + "user_id" INTEGER NOT NULL, + "application_id" INTEGER NOT NULL, + "redirect_uri" TEXT NOT NULL, + "scopes" "OAuthScope"[], + "code_challenge" TEXT, + "code_challenge_method" TEXT, + "expires_at" TIMESTAMP(3) NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAccessToken" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "token" TEXT NOT NULL, + "user_id" INTEGER NOT NULL, + "application_id" INTEGER NOT NULL, + "scopes" "OAuthScope"[], + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "revoked_at" TIMESTAMP(3), + "last_used_at" TIMESTAMP(3), + "last_used_ip" TEXT, + + CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthRefreshToken" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "token" TEXT NOT NULL, + "user_id" INTEGER NOT NULL, + "application_id" INTEGER NOT NULL, + "access_token_id" INTEGER NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAuthorizationGrant" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" INTEGER NOT NULL, + "application_id" INTEGER NOT NULL, + "scopes" "OAuthScope"[], + "authorized_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_used_at" TIMESTAMP(3), + "revoked" BOOLEAN NOT NULL DEFAULT false, + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "OAuthAuthorizationGrant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthWalletInvoiceRequest" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" INTEGER NOT NULL, + "application_id" INTEGER NOT NULL, + "access_token_id" INTEGER NOT NULL, + "bolt11" TEXT NOT NULL, + "amount_msats" BIGINT NOT NULL, + "description" TEXT, + "metadata" JSONB, + "status" TEXT NOT NULL DEFAULT 'pending', + "approved" BOOLEAN, + "approved_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3) NOT NULL, + "invoice_id" INTEGER, + + CONSTRAINT "OAuthWalletInvoiceRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthApiUsage" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "application_id" INTEGER NOT NULL, + "access_token_id" INTEGER, + "endpoint" TEXT NOT NULL, + "method" TEXT NOT NULL, + "status_code" INTEGER NOT NULL, + "response_time_ms" INTEGER, + "user_id" INTEGER, + "ip_address" TEXT, + "user_agent" TEXT, + + CONSTRAINT "OAuthApiUsage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthApplication_client_id_key" ON "OAuthApplication"("client_id"); + +-- CreateIndex +CREATE INDEX "OAuthApplication_user_id_idx" ON "OAuthApplication"("user_id"); + +-- CreateIndex +CREATE INDEX "OAuthApplication_approved_idx" ON "OAuthApplication"("approved"); + +-- CreateIndex +CREATE INDEX "OAuthApplication_suspended_idx" ON "OAuthApplication"("suspended"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_user_id_idx" ON "OAuthAuthorizationCode"("user_id"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_application_id_idx" ON "OAuthAuthorizationCode"("application_id"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_expires_at_idx" ON "OAuthAuthorizationCode"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_user_id_idx" ON "OAuthAccessToken"("user_id"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_application_id_idx" ON "OAuthAccessToken"("application_id"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_expires_at_idx" ON "OAuthAccessToken"("expires_at"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_revoked_idx" ON "OAuthAccessToken"("revoked"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthRefreshToken_access_token_id_key" ON "OAuthRefreshToken"("access_token_id"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_user_id_idx" ON "OAuthRefreshToken"("user_id"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_application_id_idx" ON "OAuthRefreshToken"("application_id"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_expires_at_idx" ON "OAuthRefreshToken"("expires_at"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_revoked_idx" ON "OAuthRefreshToken"("revoked"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthorizationGrant_user_id_application_id_key" ON "OAuthAuthorizationGrant"("user_id", "application_id"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationGrant_application_id_idx" ON "OAuthAuthorizationGrant"("application_id"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationGrant_revoked_idx" ON "OAuthAuthorizationGrant"("revoked"); + +-- CreateIndex +CREATE INDEX "OAuthWalletInvoiceRequest_user_id_idx" ON "OAuthWalletInvoiceRequest"("user_id"); + +-- CreateIndex +CREATE INDEX "OAuthWalletInvoiceRequest_application_id_idx" ON "OAuthWalletInvoiceRequest"("application_id"); + +-- CreateIndex +CREATE INDEX "OAuthWalletInvoiceRequest_access_token_id_idx" ON "OAuthWalletInvoiceRequest"("access_token_id"); + +-- CreateIndex +CREATE INDEX "OAuthWalletInvoiceRequest_status_idx" ON "OAuthWalletInvoiceRequest"("status"); + +-- CreateIndex +CREATE INDEX "OAuthWalletInvoiceRequest_expires_at_idx" ON "OAuthWalletInvoiceRequest"("expires_at"); + +-- CreateIndex +CREATE INDEX "OAuthApiUsage_application_id_idx" ON "OAuthApiUsage"("application_id"); + +-- CreateIndex +CREATE INDEX "OAuthApiUsage_access_token_id_idx" ON "OAuthApiUsage"("access_token_id"); + +-- CreateIndex +CREATE INDEX "OAuthApiUsage_created_at_idx" ON "OAuthApiUsage"("created_at"); + +-- CreateIndex +CREATE INDEX "OAuthApiUsage_user_id_idx" ON "OAuthApiUsage"("user_id"); + +-- AddForeignKey +ALTER TABLE "OAuthApplication" ADD CONSTRAINT "OAuthApplication_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_access_token_id_fkey" FOREIGN KEY ("access_token_id") REFERENCES "OAuthAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationGrant" ADD CONSTRAINT "OAuthAuthorizationGrant_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationGrant" ADD CONSTRAINT "OAuthAuthorizationGrant_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_access_token_id_fkey" FOREIGN KEY ("access_token_id") REFERENCES "OAuthAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_invoice_id_fkey" FOREIGN KEY ("invoice_id") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_access_token_id_fkey" FOREIGN KEY ("access_token_id") REFERENCES "OAuthAccessToken"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb92eab87..69aa476db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,6 +152,13 @@ model User { DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") UserSubTrust UserSubTrust[] + OAuthApplications OAuthApplication[] + OAuthAuthorizationCodes OAuthAuthorizationCode[] + OAuthAccessTokens OAuthAccessToken[] + OAuthRefreshTokens OAuthRefreshToken[] + OAuthAuthorizationGrants OAuthAuthorizationGrant[] + OAuthWalletInvoiceRequests OAuthWalletInvoiceRequest[] + OAuthApiUsage OAuthApiUsage[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -994,6 +1001,7 @@ model Invoice { PollVote PollVote[] PollBlindVote PollBlindVote[] WalletLog WalletLog[] + OAuthWalletInvoiceRequests OAuthWalletInvoiceRequest[] @@index([createdAt], map: "Invoice.created_at_index") @@index([userId], map: "Invoice.userId_index") @@ -1298,3 +1306,204 @@ enum LogLevel { ERROR SUCCESS } + +enum OAuthGrantType { + authorization_code + refresh_token + client_credentials +} + +enum OAuthScope { + read + write_posts @map("write:posts") + write_comments @map("write:comments") + wallet_read @map("wallet:read") + wallet_send @map("wallet:send") + wallet_receive @map("wallet:receive") + profile_read @map("profile:read") + profile_write @map("profile:write") + notifications_read @map("notifications:read") + notifications_write @map("notifications:write") +} + +enum OAuthTokenType { + bearer +} + +model OAuthApplication { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + name String + description String? + homepageUrl String? @map("homepage_url") + privacyPolicyUrl String? @map("privacy_policy_url") + termsOfServiceUrl String? @map("terms_of_service_url") + clientId String @unique @map("client_id") + clientSecretHash String @map("client_secret_hash") + redirectUris String[] @map("redirect_uris") + scopes OAuthScope[] + logoUrl String? @map("logo_url") + isConfidential Boolean @default(true) @map("is_confidential") + pkceRequired Boolean @default(true) @map("pkce_required") + userId Int @map("user_id") + approved Boolean @default(false) + suspended Boolean @default(false) + suspendedReason String? @map("suspended_reason") + rateLimitRpm Int? @default(100) @map("rate_limit_rpm") + rateLimitDaily Int? @default(5000) @map("rate_limit_daily") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + authorizationCodes OAuthAuthorizationCode[] + accessTokens OAuthAccessToken[] + refreshTokens OAuthRefreshToken[] + authorizationGrants OAuthAuthorizationGrant[] + walletInvoiceRequests OAuthWalletInvoiceRequest[] + apiUsage OAuthApiUsage[] + + @@index([userId]) + @@index([approved]) + @@index([suspended]) +} + +model OAuthAuthorizationCode { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + code String @unique + userId Int @map("user_id") + applicationId Int @map("application_id") + redirectUri String @map("redirect_uri") + scopes OAuthScope[] + codeChallenge String? @map("code_challenge") + codeChallengeMethod String? @map("code_challenge_method") + expiresAt DateTime @map("expires_at") + used Boolean @default(false) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([applicationId]) + @@index([expiresAt]) +} + +model OAuthAccessToken { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + token String @unique + userId Int @map("user_id") + applicationId Int @map("application_id") + scopes OAuthScope[] + expiresAt DateTime @map("expires_at") + revoked Boolean @default(false) + revokedAt DateTime? @map("revoked_at") + lastUsedAt DateTime? @map("last_used_at") + lastUsedIp String? @map("last_used_ip") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + refreshToken OAuthRefreshToken? + walletInvoiceRequests OAuthWalletInvoiceRequest[] + apiUsage OAuthApiUsage[] + + @@index([userId]) + @@index([applicationId]) + @@index([expiresAt]) + @@index([revoked]) +} + +model OAuthRefreshToken { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + token String @unique + userId Int @map("user_id") + applicationId Int @map("application_id") + accessTokenId Int @unique @map("access_token_id") + expiresAt DateTime @map("expires_at") + revoked Boolean @default(false) + revokedAt DateTime? @map("revoked_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + accessToken OAuthAccessToken @relation(fields: [accessTokenId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([applicationId]) + @@index([expiresAt]) + @@index([revoked]) +} + +model OAuthAuthorizationGrant { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int @map("user_id") + applicationId Int @map("application_id") + scopes OAuthScope[] + authorizedAt DateTime @default(now()) @map("authorized_at") + lastUsedAt DateTime? @map("last_used_at") + revoked Boolean @default(false) + revokedAt DateTime? @map("revoked_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + @@unique([userId, applicationId]) + @@index([applicationId]) + @@index([revoked]) +} + +model OAuthWalletInvoiceRequest { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int @map("user_id") + applicationId Int @map("application_id") + accessTokenId Int @map("access_token_id") + bolt11 String + amountMsats BigInt @map("amount_msats") + description String? + metadata Json? @db.JsonB + status String @default("pending") + approved Boolean? + approvedAt DateTime? @map("approved_at") + expiresAt DateTime @map("expires_at") + invoiceId Int? @map("invoice_id") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + accessToken OAuthAccessToken @relation(fields: [accessTokenId], references: [id], onDelete: Cascade) + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([applicationId]) + @@index([accessTokenId]) + @@index([status]) + @@index([expiresAt]) +} + +model OAuthApiUsage { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + applicationId Int @map("application_id") + accessTokenId Int? @map("access_token_id") + endpoint String + method String + statusCode Int @map("status_code") + responseTimeMs Int? @map("response_time_ms") + userId Int? @map("user_id") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + + application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + accessToken OAuthAccessToken? @relation(fields: [accessTokenId], references: [id], onDelete: SetNull) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([applicationId]) + @@index([accessTokenId]) + @@index([createdAt]) + @@index([userId]) +} diff --git a/public/oauth-service-worker.js b/public/oauth-service-worker.js new file mode 100644 index 000000000..6e3db9c65 --- /dev/null +++ b/public/oauth-service-worker.js @@ -0,0 +1,254 @@ +/* eslint-env serviceworker */ +/* global self */ + +// OAuth Service Worker for handling wallet notifications +// This allows OAuth apps to receive payment notifications even when not in focus + +const STACKER_NEWS_ORIGIN = self.location.origin + +// Cache for OAuth access tokens and app info +let oauthAppInfo = null +let accessToken = null + +// Listen for messages from the main application +self.addEventListener('message', async (event) => { + const { type, data } = event.data + + switch (type) { + case 'OAUTH_INIT': + oauthAppInfo = data.appInfo + accessToken = data.accessToken + console.log('OAuth Service Worker initialized for app:', oauthAppInfo.name) + break + + case 'OAUTH_PAYMENT_REQUEST': + await handlePaymentRequest(data) + break + + case 'OAUTH_INVOICE_REQUEST': + await handleInvoiceRequest(data) + break + + default: + console.log('Unknown message type:', type) + } +}) + +// Listen for push notifications from Stacker News server +self.addEventListener('push', async (event) => { + if (!event.data) return + + try { + const notification = event.data.json() + + if (notification.type === 'oauth_payment_approval_required') { + await handlePaymentApprovalRequired(notification) + } else if (notification.type === 'oauth_invoice_paid') { + await handleInvoicePaid(notification) + } else if (notification.type === 'oauth_payment_completed') { + await handlePaymentCompleted(notification) + } + } catch (error) { + console.error('Error handling push notification:', error) + } +}) + +// Handle payment requests from OAuth apps +async function handlePaymentRequest (paymentData) { + try { + const response = await fetch(`${STACKER_NEWS_ORIGIN}/api/oauth/wallet/send`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(paymentData) + }) + + const result = await response.json() + + // Send result back to the OAuth app + const clients = await self.clients.matchAll() + clients.forEach(client => { + client.postMessage({ + type: 'OAUTH_PAYMENT_RESPONSE', + data: result, + requestId: paymentData.requestId + }) + }) + + // If approval is required, show notification + if (result.status === 'pending_approval') { + await self.registration.showNotification('Payment Approval Required', { + body: `${oauthAppInfo.name} wants to send ${result.amount_sats} sats`, + icon: '/bitcoin-logo.png', + badge: '/bitcoin-logo.png', + tag: `payment-approval-${result.payment_id}`, + requireInteraction: true, + actions: [ + { action: 'approve', title: 'Approve' }, + { action: 'deny', title: 'Deny' } + ], + data: { + type: 'payment_approval', + paymentId: result.payment_id, + appName: oauthAppInfo.name + } + }) + } + } catch (error) { + console.error('Error handling payment request:', error) + } +} + +// Handle invoice creation requests +async function handleInvoiceRequest (invoiceData) { + try { + const response = await fetch(`${STACKER_NEWS_ORIGIN}/api/oauth/wallet/invoices`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(invoiceData) + }) + + const result = await response.json() + + // Send result back to the OAuth app + const clients = await self.clients.matchAll() + clients.forEach(client => { + client.postMessage({ + type: 'OAUTH_INVOICE_RESPONSE', + data: result, + requestId: invoiceData.requestId + }) + }) + } catch (error) { + console.error('Error handling invoice request:', error) + } +} + +// Handle payment approval required notifications +async function handlePaymentApprovalRequired (notification) { + await self.registration.showNotification('Payment Approval Required', { + body: `${notification.appName} wants to send ${notification.amountSats} sats`, + icon: '/bitcoin-logo.png', + badge: '/bitcoin-logo.png', + tag: `payment-approval-${notification.paymentId}`, + requireInteraction: true, + actions: [ + { action: 'approve', title: 'Approve' }, + { action: 'deny', title: 'Deny' } + ], + data: { + type: 'payment_approval', + paymentId: notification.paymentId, + appName: notification.appName + } + }) +} + +// Handle invoice paid notifications +async function handleInvoicePaid (notification) { + await self.registration.showNotification('Invoice Paid', { + body: `Received ${notification.amountSats} sats from ${notification.appName}`, + icon: '/bitcoin-logo.png', + badge: '/bitcoin-logo.png', + tag: `invoice-paid-${notification.invoiceId}`, + data: { + type: 'invoice_paid', + invoiceId: notification.invoiceId, + appName: notification.appName + } + }) + + // Notify the OAuth app + const clients = await self.clients.matchAll() + clients.forEach(client => { + client.postMessage({ + type: 'OAUTH_INVOICE_PAID', + data: notification + }) + }) +} + +// Handle payment completed notifications +async function handlePaymentCompleted (notification) { + await self.registration.showNotification('Payment Sent', { + body: `Sent ${notification.amountSats} sats via ${notification.appName}`, + icon: '/bitcoin-logo.png', + badge: '/bitcoin-logo.png', + tag: `payment-sent-${notification.paymentId}`, + data: { + type: 'payment_completed', + paymentId: notification.paymentId, + appName: notification.appName + } + }) + + // Notify the OAuth app + const clients = await self.clients.matchAll() + clients.forEach(client => { + client.postMessage({ + type: 'OAUTH_PAYMENT_COMPLETED', + data: notification + }) + }) +} + +// Handle notification clicks +self.addEventListener('notificationclick', async (event) => { + event.notification.close() + + const { type, paymentId } = event.notification.data + + if (type === 'payment_approval') { + if (event.action === 'approve') { + // Open approval page + await self.clients.openWindow(`${STACKER_NEWS_ORIGIN}/oauth/approve-payment/${paymentId}`) + } else if (event.action === 'deny') { + // Send denial to server + try { + await fetch(`${STACKER_NEWS_ORIGIN}/api/oauth/wallet/approve-payment/${paymentId}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ approved: false }) + }) + } catch (error) { + console.error('Error denying payment:', error) + } + } else { + // Default click - open approval page + await self.clients.openWindow(`${STACKER_NEWS_ORIGIN}/oauth/approve-payment/${paymentId}`) + } + } else { + // For other notification types, just focus or open the main app + const existingClients = await self.clients.matchAll() + if (existingClients.length > 0) { + existingClients[0].focus() + } else { + await self.clients.openWindow(STACKER_NEWS_ORIGIN) + } + } +}) + +// Clean up old notifications +self.addEventListener('notificationclose', (event) => { + // Could implement cleanup logic here if needed +}) + +// Install event +self.addEventListener('install', (event) => { + console.log('OAuth Service Worker installed') + self.skipWaiting() +}) + +// Activate event +self.addEventListener('activate', (event) => { + console.log('OAuth Service Worker activated') + event.waitUntil(self.clients.claim()) +}) From 05e76031a08e50ef6a85296ab039fb81e954c9dc Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 28 Jun 2025 07:31:38 +0200 Subject: [PATCH 2/7] feat: Add OAuth application management for users --- api/resolvers/admin.js | 10 + api/typeDefs/admin.js | 1 + api/typeDefs/index.js | 4 +- api/typeDefs/oauth.js | 57 ++++++ pages/admin/oauth-applications.js | 177 +++++++++++++++++ pages/api/admin/oauth-applications.js | 31 +++ pages/api/oauth/authorized-applications.js | 56 ++++++ pages/api/oauth/wallet/invoices.js | 2 +- pages/api/oauth/wallet/send.js | 17 +- .../settings/authorized-oauth-applications.js | 182 ++++++++++++++++++ pages/settings/index.js | 5 + worker/paidAction.js | 22 +++ 12 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 api/typeDefs/oauth.js create mode 100644 pages/admin/oauth-applications.js create mode 100644 pages/api/admin/oauth-applications.js create mode 100644 pages/api/oauth/authorized-applications.js create mode 100644 pages/settings/authorized-oauth-applications.js diff --git a/api/resolvers/admin.js b/api/resolvers/admin.js index ab68f3bbf..e7b15e1dc 100644 --- a/api/resolvers/admin.js +++ b/api/resolvers/admin.js @@ -15,6 +15,16 @@ export default { const { id, live } = await models.snl.findFirst() await models.snl.update({ where: { id }, data: { live: !live } }) return !live + }, + approveOAuthApplication: async (parent, { id }, { models, me }) => { + if (!me || !SN_ADMIN_IDS.includes(me.id)) { + throw new Error('not an admin') + } + const app = await models.oAuthApplication.update({ + where: { id: Number(id) }, + data: { approved: true } + }) + return app } } } diff --git a/api/typeDefs/admin.js b/api/typeDefs/admin.js index 8d82f1726..7b87b8211 100644 --- a/api/typeDefs/admin.js +++ b/api/typeDefs/admin.js @@ -7,5 +7,6 @@ export default gql` extend type Mutation { onAirToggle: Boolean! + approveOAuthApplication(id: ID!): OAuthApplication! } ` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e427..70b804c6c 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -20,6 +20,8 @@ import chainFee from './chainFee' import paidAction from './paidAction' import vault from './vault' +import oauth from './oauth' + const common = gql` type Query { _: Boolean @@ -39,4 +41,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault, oauth] diff --git a/api/typeDefs/oauth.js b/api/typeDefs/oauth.js new file mode 100644 index 000000000..a38cea87d --- /dev/null +++ b/api/typeDefs/oauth.js @@ -0,0 +1,57 @@ +import { gql } from 'graphql-tag' + +export default gql` + type OAuthApplication { + id: ID! + createdAt: Date! + updatedAt: Date! + name: String! + description: String + homepageUrl: String + privacyPolicyUrl: String + termsOfServiceUrl: String + clientId: String! + clientSecretHash: String + redirectUris: [String!]! + scopes: [String!]! + logoUrl: String + userId: ID! + approved: Boolean! + suspended: Boolean! + suspendedReason: String + rateLimitRpm: Int + rateLimitDaily: Int + isConfidential: Boolean! + pkceRequired: Boolean! + } + + extend type Query { + oAuthApplications: [OAuthApplication!]! + oAuthApplication(id: ID!): OAuthApplication + } + + extend type Mutation { + createOAuthApplication( + name: String! + description: String + homepageUrl: String + privacyPolicyUrl: String + termsOfServiceUrl: String + redirectUris: [String!]! + scopes: [String!]! + logoUrl: String + ): OAuthApplication! + updateOAuthApplication( + id: ID! + name: String + description: String + homepageUrl: String + privacyPolicyUrl: String + termsOfServiceUrl: String + redirectUris: [String!] + scopes: [String!] + logoUrl: String + ): OAuthApplication! + deleteOAuthApplication(id: ID!): OAuthApplication + } +` diff --git a/pages/admin/oauth-applications.js b/pages/admin/oauth-applications.js new file mode 100644 index 000000000..8084fda6f --- /dev/null +++ b/pages/admin/oauth-applications.js @@ -0,0 +1,177 @@ +import { useState, useEffect } from 'react' +import { Container, Card, Button, Table, Badge, Alert, Spinner } from 'react-bootstrap' +import Layout from '../../components/layout' +import { formatDistanceToNow } from 'date-fns' +import { getGetServerSideProps } from '../../api/ssrApollo' +import { useMe } from '../../components/me' +import { useMutation, gql } from '@apollo/client' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function AdminOAuthApplications () { + const { me } = useMe() + const [applications, setApplications] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const [approveApplication] = useMutation(gql` + mutation approveOAuthApplication($id: ID!) { + approveOAuthApplication(id: $id) { + id + approved + } + } + `) + + useEffect(() => { + if (me) { + fetchApplications() + } + }, [me]) + + const fetchApplications = async () => { + try { + const response = await fetch('/api/admin/oauth-applications') + if (!response.ok) throw new Error('Failed to fetch applications') + const data = await response.json() + setApplications(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleApprove = async (id) => { + if (!window.confirm('Are you sure you want to approve this application?')) { + return + } + + try { + await approveApplication({ variables: { id } }) + await fetchApplications() + } catch (err) { + setError(err.message) + } + } + + if (loading) { + return ( + + +
+ +
+
+
+ ) + } + + if (!me || !me.isAdmin) { + return ( + + + + You are not authorized to view this page. + + + + ) + } + + return ( + + +

Pending OAuth Applications

+ + {error && {error}} + + {applications.length === 0 + ? ( + + +
No Pending OAuth Applications
+

+ All OAuth applications have been approved or there are no new applications. +

+
+
+ ) + : ( + + + + + + + + + + + + + {applications.map(app => ( + + + + + + + + ))} + +
ApplicationClient IDScopesCreatedActions
+
+ {app.logoUrl + ? ( + {app.name} + ) + : ( +
+ 🔗 +
+ )} +
+
{app.name}
+ {app.description && ( + {app.description} + )} +
+
+
+ {app.clientId} + +
+ {app.scopes.map(scope => ( + + {scope} + + ))} +
+
+ + {formatDistanceToNow(new Date(app.createdAt), { addSuffix: true })} + + + +
+
+ )} +
+
+ ) +} diff --git a/pages/api/admin/oauth-applications.js b/pages/api/admin/oauth-applications.js new file mode 100644 index 000000000..1070cf26b --- /dev/null +++ b/pages/api/admin/oauth-applications.js @@ -0,0 +1,31 @@ +import { getSession } from 'next-auth/react' +import prisma from '@/api/models' +import { SN_ADMIN_IDS } from '@/lib/constants' + +export default async function handler (req, res) { + const session = await getSession({ req }) + + if (!session || !SN_ADMIN_IDS.includes(session.user.id)) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + if (req.method === 'GET') { + try { + const applications = await prisma.oAuthApplication.findMany({ + where: { + approved: false + }, + orderBy: { + createdAt: 'asc' + } + }) + return res.status(200).json(applications) + } catch (error) { + console.error(error) + return res.status(500).json({ error: 'Internal Server Error' }) + } + } else { + res.setHeader('Allow', ['GET']) + res.status(405).end(`Method ${req.method} Not Allowed`) + } +} diff --git a/pages/api/oauth/authorized-applications.js b/pages/api/oauth/authorized-applications.js new file mode 100644 index 000000000..b0f6ff991 --- /dev/null +++ b/pages/api/oauth/authorized-applications.js @@ -0,0 +1,56 @@ +import { getSession } from 'next-auth/react' +import prisma from '@/api/models' + +export default async function handler (req, res) { + const session = await getSession({ req }) + + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + if (req.method === 'GET') { + try { + const grants = await prisma.oAuthAuthorizationGrant.findMany({ + where: { + userId: session.user.id, + revokedAt: null + }, + include: { + application: true + }, + orderBy: { + createdAt: 'desc' + } + }) + return res.status(200).json(grants) + } catch (error) { + console.error(error) + return res.status(500).json({ error: 'Internal Server Error' }) + } + } else if (req.method === 'DELETE') { + const { id } = req.query + + if (!id) { + return res.status(400).json({ error: 'Grant ID is required' }) + } + + try { + await prisma.oAuthAuthorizationGrant.updateMany({ + where: { + id: String(id), + userId: session.user.id + }, + data: { + revokedAt: new Date() + } + }) + return res.status(204).end() + } catch (error) { + console.error(error) + return res.status(500).json({ error: 'Internal Server Error' }) + } + } else { + res.setHeader('Allow', ['GET', 'DELETE']) + res.status(405).end(`Method ${req.method} Not Allowed`) + } +} diff --git a/pages/api/oauth/wallet/invoices.js b/pages/api/oauth/wallet/invoices.js index cb0a3f892..1c92f8628 100644 --- a/pages/api/oauth/wallet/invoices.js +++ b/pages/api/oauth/wallet/invoices.js @@ -175,7 +175,7 @@ async function createInvoice (req, res) { amount_requested_msats: invoice.msatsRequested.toString(), amount_requested_sats: Math.floor(Number(invoice.msatsRequested) / 1000), description: invoice.desc, - status: 'pending', + status: getInvoiceStatus(invoice), expires_at: invoice.expiresAt.toISOString(), created_at: invoice.createdAt.toISOString(), request_id: invoiceRequest.id diff --git a/pages/api/oauth/wallet/send.js b/pages/api/oauth/wallet/send.js index 52e38ad91..6a82ddb16 100644 --- a/pages/api/oauth/wallet/send.js +++ b/pages/api/oauth/wallet/send.js @@ -1,6 +1,8 @@ import { authenticateOAuth } from '../../../../lib/oauth-auth' import models from '../../../../api/models' -import { parsePaymentRequest } from '@/lib/bolt11' +import { parsePaymentRequest } from 'ln-service' +import { createWithdrawal } from '../../../../api/resolvers/wallet' +import lnd from '../../../../api/lnd' export default async function handler (req, res) { if (req.method !== 'POST') { @@ -78,28 +80,27 @@ export default async function handler (req, res) { if (amountMsats <= autoApproveThreshold) { // In a real implementation, this would actually send the payment - // For now, we'll simulate it + const withdrawal = await createWithdrawal(null, { invoice: bolt11, maxFee: maxFeeMsats ? Number(maxFeeMsats) : undefined }, { me: user, models, lnd }) await models.oAuthWalletInvoiceRequest.update({ where: { id: paymentRequest.id }, data: { status: 'approved', approved: true, - approvedAt: new Date() + approvedAt: new Date(), + withdrawalId: withdrawal.id } }) - // TODO: Implement actual payment sending using the user's configured wallet - // This would integrate with the existing wallet system - return res.status(200).json({ payment_id: paymentRequest.id, - status: 'approved', + status: withdrawal.status === 'CONFIRMED' ? 'complete' : 'pending', amount_msats: amountMsats.toString(), amount_sats: Math.floor(Number(amountMsats) / 1000), destination: parsedPaymentRequest.destination, description: parsedPaymentRequest.description, - approved_at: new Date().toISOString() + approved_at: new Date().toISOString(), + withdrawal_id: withdrawal.id }) } else { // Requires manual approval diff --git a/pages/settings/authorized-oauth-applications.js b/pages/settings/authorized-oauth-applications.js new file mode 100644 index 000000000..a2804d2d6 --- /dev/null +++ b/pages/settings/authorized-oauth-applications.js @@ -0,0 +1,182 @@ +import { useState, useEffect } from 'react' +import { Container, Row, Col, Card, Button, Table, Badge, Alert, Spinner } from 'react-bootstrap' +import Layout from '../../components/layout' +import { formatDistanceToNow } from 'date-fns' +import { getGetServerSideProps } from '../../api/ssrApollo' +import { SettingsHeader } from './index' +import { useMe } from '../../components/me' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function AuthorizedOAuthApplications () { + const { me } = useMe() + const [grants, setGrants] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + if (me) { + fetchGrants() + } + }, [me]) + + const fetchGrants = async () => { + try { + const response = await fetch('/api/oauth/authorized-applications') + if (!response.ok) throw new Error('Failed to fetch authorized applications') + const data = await response.json() + setGrants(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleRevoke = async (id) => { + if (!window.confirm('Are you sure you want to revoke access for this application?')) { + return + } + + try { + const response = await fetch(`/api/oauth/authorized-applications/${id}`, { + method: 'DELETE' + }) + + if (!response.ok) throw new Error('Failed to revoke access') + + await fetchGrants() + } catch (err) { + setError(err.message) + } + } + + const getScopeVariant = (scope) => { + if (scope.startsWith('wallet:')) return 'warning' + if (scope.startsWith('write:')) return 'info' + return 'secondary' + } + + if (loading) { + return ( + + +
+ +
+
+
+ ) + } + + return ( + +
+ + + + +
+

Authorized Applications

+
+ + {error && {error}} + + {grants.length === 0 + ? ( + + +
No Authorized Applications
+

+ You have not authorized any applications to access your Stacker News account. +

+
+
+ ) + : ( + + + + + + + + + + + + + {grants.map(grant => ( + + + + + + + + ))} + +
ApplicationScopesAuthorizedExpiresActions
+
+ {grant.application.logoUrl + ? ( + {grant.application.name} + ) + : ( +
+ 🔗 +
+ )} +
+
{grant.application.name}
+ {grant.application.description && ( + {grant.application.description} + )} +
+
+
+
+ {grant.scopes.map(scope => ( + + {scope} + + ))} +
+
+ + {formatDistanceToNow(new Date(grant.createdAt), { addSuffix: true })} + + + + {grant.expiresAt ? formatDistanceToNow(new Date(grant.expiresAt), { addSuffix: true }) : 'Never'} + + + +
+
+ )} + +
+
+
+
+ ) +} diff --git a/pages/settings/index.js b/pages/settings/index.js index 0e3333534..e336df8ff 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -87,6 +87,11 @@ export function SettingsHeader () { oauth apps + + + authorized apps + + ) diff --git a/worker/paidAction.js b/worker/paidAction.js index d628e3fd8..de63d91d0 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -10,6 +10,7 @@ import { getInvoice, parsePaymentRequest, payViaPaymentRequest, settleHodlInvoice } from 'ln-service' +import { sendUserNotification } from '@/lib/webPush' import { MIN_SETTLEMENT_CLTV_DELTA } from '@/wallets/wrap' // aggressive finalization retry options @@ -171,6 +172,27 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln INSERT INTO pgboss.job (name, data) VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))` + // If it's an OAuth invoice, send a push notification + if (dbInvoice.actionType === 'RECEIVE' && dbInvoice.oAuthWalletInvoiceRequest) { + const oAuthRequest = await tx.oAuthWalletInvoiceRequest.findUnique({ + where: { invoiceId: dbInvoice.id }, + include: { oauthApplication: true } + }) + if (oAuthRequest && oAuthRequest.oauthApplication) { + await sendUserNotification(dbInvoice.userId, { + title: 'Invoice Paid', + body: `Received ${Math.floor(Number(lndInvoice.received_mtokens) / 1000)} sats from ${oAuthRequest.oauthApplication.name}`, + tag: `oauth_invoice_paid-${dbInvoice.id}`, + data: { + type: 'oauth_invoice_paid', + invoiceId: dbInvoice.id, + appName: oAuthRequest.oauthApplication.name, + amountSats: Math.floor(Number(lndInvoice.received_mtokens) / 1000) + } + }) + } + } + return updateFields }, ...args From d39933def3ad1946ae44115551a4768185c9900c Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 28 Jun 2025 07:34:24 +0200 Subject: [PATCH 3/7] docs: add developer docs for OAuth API --- docs/dev/oauth-api.md | 304 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/dev/oauth-api.md diff --git a/docs/dev/oauth-api.md b/docs/dev/oauth-api.md new file mode 100644 index 000000000..d75c77883 --- /dev/null +++ b/docs/dev/oauth-api.md @@ -0,0 +1,304 @@ +# Stacker News OAuth 2.0 API + +This document provides a high-level overview of the Stacker News OAuth 2.0 API, enabling third-party applications to securely interact with user accounts and wallets. + +## 1. Introduction + +OAuth 2.0 is an industry-standard protocol for authorization. It allows a third-party application to obtain limited access to a user's resources on an HTTP service, without exposing the user's credentials. Stacker News leverages OAuth 2.0 to provide a secure and standardized way for developers to build applications that interact with the Stacker News platform on behalf of users. + +### Why Stacker News OAuth? + +Stacker News OAuth offers several benefits: + +* **For Developers**: It simplifies the process of integrating with Stacker News, allowing you to build innovative applications that can, for example, post content, manage user wallets, or interact with user profiles, all while respecting user privacy and security. +* **For Users**: Users maintain full control over their data and permissions. They can grant or revoke access to third-party applications at any time, and they are always informed about what permissions an application is requesting. + +### Key Features + +The Stacker News OAuth implementation includes: + +* **Authorization Code Grant with PKCE**: A secure and widely recommended OAuth 2.0 flow that prevents authorization code interception attacks. +* **Application Management UI**: Developers can register and manage their OAuth applications directly within the Stacker News platform via `/settings/oauth-applications`. +* **User Consent Screen**: Users are presented with a clear and concise consent screen, detailing the permissions an application is requesting before they grant access. +* **Granular Scopes**: A comprehensive set of scopes allows applications to request only the necessary permissions, adhering to the principle of least privilege. +* **Secure Wallet API**: A dedicated set of API endpoints for secure interaction with user wallets, enabling payments and invoice management. +* **Token Management**: Robust mechanisms for issuing, refreshing, and revoking access and refresh tokens. +* **Rate Limiting**: Implemented to ensure fair usage and protect the API from abuse. + +## 2. Getting Started + +### Registering Your Application + +To begin using the Stacker News OAuth 2.0 API, you must first register your application. This is done through the dedicated UI at `/settings/oauth-applications`. + +When registering, you will need to provide the following information: + +* **Application Name**: A user-friendly name for your application that will be displayed to users during the consent process. +* **Description**: A brief explanation of what your application does. +* **Homepage URL**: The main website or landing page for your application. +* **Privacy Policy URL**: A link to your application's privacy policy, informing users how their data will be handled. +* **Terms of Service URL**: A link to your application's terms of service. +* **Redirect URIs**: A comma-separated list of valid URIs to which Stacker News can redirect users after they authorize your application. These URIs must exactly match the ones used in your authorization requests. +* **Logo URL**: An optional URL to an image that will be used as your application's logo. + +Upon successful registration, Stacker News will generate a unique **Client ID** and **Client Secret** for your application. The Client ID is a public identifier for your application, while the Client Secret is a confidential key that should be kept secure and never exposed in client-side code. These credentials will be used in the OAuth flow to identify and authenticate your application. +### Authorization Flow Overview + +Stacker News utilizes the Authorization Code Grant with Proof Key for Code Exchange (PKCE) for its OAuth 2.0 flow. This flow is designed to be secure, especially for public clients (like mobile apps or single-page applications) that cannot securely store a client secret. Here's a step-by-step guide: + +1. **User Initiates Authorization**: The user begins the authorization process from your client application. This typically involves clicking a "Connect with Stacker News" or similar button. + +2. **Client Redirects to Stacker News**: Your application constructs an authorization URL and redirects the user's browser to the Stacker News authorization endpoint (`/oauth/authorize`). This URL includes parameters such as `client_id`, `redirect_uri`, `response_type` (always `code`), `scope` (the permissions your app is requesting), `state` (a CSRF token), `code_challenge`, and `code_challenge_method` (S256). + +3. **User Grants/Denies Consent**: The user is presented with a consent screen on Stacker News (`/oauth/consent`), where they can review the permissions your application is requesting. The user can then choose to grant or deny access. + +4. **Stacker News Redirects Back to Client**: If the user grants access, Stacker News redirects the user's browser back to the `redirect_uri` you provided in step 2. This redirect includes an `authorization code` and the `state` parameter. + +5. **Client Exchanges Authorization Code for Tokens**: Your application, upon receiving the authorization code, makes a direct back-channel request to the Stacker News token endpoint (`/oauth/token`). This request includes the `authorization code`, `client_id`, `redirect_uri`, and crucially, the `code_verifier` (which corresponds to the `code_challenge` sent in step 2). Stacker News verifies these parameters and, if valid, issues an `Access Token` and a `Refresh Token` to your application. + +## 3. API Endpoints + +### Authorization Endpoint: `/oauth/authorize` + +This endpoint is used to initiate the OAuth 2.0 authorization flow. Your application will redirect the user's browser to this URL. + +**Parameters:** + +* `client_id` (required): The public identifier for your application, obtained during registration. +* `redirect_uri` (required): The URI to which Stacker News will redirect the user after they grant or deny authorization. This must be one of the registered redirect URIs for your application. +* `response_type` (required): Must be `code` for the Authorization Code Grant flow. +* `scope` (required): A space-separated list of scopes your application is requesting (e.g., `read wallet:read`). +* `state` (recommended): A unique, non-guessable string generated by your application to prevent Cross-Site Request Forgery (CSRF) attacks. This value will be returned to your `redirect_uri`. +* `code_challenge` (required): A URL-safe, base64-encoded SHA256 hash of the `code_verifier`. This is part of the PKCE flow. +* `code_challenge_method` (required): Must be `S256`, indicating the SHA256 hash method was used for the `code_challenge`. + +### Token Endpoint: `/oauth/token` + +This endpoint is used by your application to exchange the authorization code for an Access Token and Refresh Token. This is a back-channel request and should not involve the user's browser. + +**Parameters:** + +* `grant_type` (required): Must be `authorization_code` when exchanging an authorization code, or `refresh_token` when refreshing an access token. +* `client_id` (required): Your application's public identifier. +* `client_secret` (required): Your application's confidential secret. This parameter is only required for confidential clients. For public clients using PKCE, this is not typically sent. +* `redirect_uri` (required): The same `redirect_uri` that was used in the authorization request. +* `code` (required, for `authorization_code` grant type): The authorization code received from the authorization endpoint. +* `code_verifier` (required, for `authorization_code` grant type): The original cryptographically random string that was used to generate the `code_challenge`. +* `refresh_token` (required, for `refresh_token` grant type): The refresh token obtained during a previous token exchange. + +**Successful Response (Example):** + +```json +{ + "access_token": "YOUR_ACCESS_TOKEN", + "token_type": "Bearer", + "expires_in": 3600, // seconds + "refresh_token": "YOUR_REFRESH_TOKEN", + "scope": "read wallet:read" +} +``` + +### User Info Endpoint: `/api/oauth/userinfo` + +This endpoint provides basic information about the authenticated user. It requires a valid Access Token. + +**Method**: `GET` + +**Authentication**: Bearer Token (in `Authorization` header) + +**Example Request:** + +``` +GET /api/oauth/userinfo +Authorization: Bearer YOUR_ACCESS_TOKEN +``` + +**Successful Response (Example):** + +```json +{ + "id": 123, + "name": "satoshi", + "created_at": "2023-01-01T12:00:00.000Z", + "sats": 100000, + "free_posts": 5, + "free_comments": 10, + "streak": 7, + "photoId": 456, + "hideCowboy": false, + "lastPost": "2023-06-20T10:00:00.000Z", + "lastComment": "2023-06-20T11:00:00.000Z", + "lastMute": null, + "bio": "A brief bio of the user." +} +``` + +### Wallet API Endpoints + +These endpoints allow your application to interact with the user's Stacker News wallet. All wallet API calls require a valid Access Token with the appropriate `wallet` scopes. + +#### Get Wallet Balance: `/api/oauth/wallet/balance` + +**Method**: `GET` + +**Required Scope**: `wallet:read` + +**Description**: Retrieves the current balance of the user's wallet. + +**Successful Response (Example):** + +```json +{ + "balance": 100000 // in sats +} +``` + +#### Manage Invoices: `/api/oauth/wallet/invoices` + +**Method**: `GET` (list invoices), `POST` (create new invoice) + +**Required Scopes**: `wallet:read` (for GET), `wallet:receive` (for POST) + +**Description**: Lists existing invoices or creates a new invoice for receiving payments. + +**Parameters for POST Request:** + +* `amount_msats` (required): The amount to receive in millisatoshis. Either `amount_msats` or `amount_sats` must be provided. +* `amount_sats` (required): The amount to receive in satoshis. Either `amount_msats` or `amount_sats` must be provided. +* `description` (optional): A brief description for the invoice. +* `expiry_seconds` (optional): The invoice expiry time in seconds (default: 3600 seconds). + +**Successful POST Response (Example):** + +```json +{ + "bolt11": "lnbc...", + "hash": "...", + "expires_at": "..." +} +``` + +#### Send Payments: `/api/oauth/wallet/send` + +**Method**: `POST` + +**Required Scope**: `wallet:send` + +**Description**: Sends a payment from the user's wallet to a specified Bolt11 invoice. + +**Parameters:** + +* `bolt11` (required): The Bolt11 invoice string to pay. +* `max_fee_msats` (optional): The maximum fee in millisatoshis that the user is willing to pay for the transaction. + +**Successful Response (Example):** + +```json +{ + "success": true, + "preimage": "..." +} +``` + +## 4. Scopes + +Scopes define the permissions your application requests from the user. When a user authorizes your application, they are presented with a consent screen detailing these scopes. It's crucial to request only the scopes necessary for your application's functionality, adhering to the principle of least privilege. + +* `read`: Read-only access to public user data, such as username, creation date, and public profile information. +* `write:posts`: Allows your application to create new posts and edit existing posts on behalf of the user. +* `write:comments`: Allows your application to create new comments and edit existing comments on behalf of the user. +* `wallet:read`: Provides read-only access to the user's wallet balance and invoice history. Your application cannot initiate payments with this scope. +* `wallet:send`: Grants permission to send payments from the user's wallet. This is a highly sensitive scope and requires explicit user approval. +* `wallet:receive`: Grants permission to create invoices for receiving payments into the user's wallet. +* `profile:read`: Provides read-only access to the user's private profile information, such as email address (if available and consented). +* `profile:write`: Allows your application to modify the user's profile information. +* `notifications:read`: Provides read-only access to the user's notifications. +* `notifications:write`: Allows your application to manage the user's notification settings. + +## 5. Token Management + +OAuth 2.0 relies on tokens for secure access to protected resources. Stacker News issues two primary types of tokens: Access Tokens and Refresh Tokens. + +### Access Tokens + +* **Purpose**: Access Tokens are short-lived credentials that grant your application access to specific API endpoints on behalf of the user. They are included in the `Authorization` header of API requests as a Bearer token. +* **Lifespan**: Access Tokens have a limited lifespan (e.g., 1 hour) for security reasons. Once expired, they can no longer be used to access protected resources. + +### Refresh Tokens + +* **Purpose**: Refresh Tokens are long-lived credentials used to obtain new Access Tokens after the current one expires, without requiring the user to re-authorize your application. +* **Lifespan**: Refresh Tokens have a longer lifespan than Access Tokens and are typically stored securely by your application. +* **Renewal**: When an Access Token expires, your application can use the Refresh Token to make a request to the Token Endpoint (`/oauth/token`) with `grant_type=refresh_token` to obtain a new Access Token and potentially a new Refresh Token. + +### Token Expiration and Renewal + +It is crucial for your application to handle Access Token expiration gracefully. When an API request returns an authentication error due to an expired Access Token, your application should: + +1. Attempt to use the Refresh Token to obtain a new Access Token. +2. If successful, retry the original API request with the new Access Token. +3. If the Refresh Token is also expired or revoked, the user will need to re-initiate the authorization flow. + +## 6. Security Considerations + +Security is paramount when dealing with user data and financial transactions. Stacker News OAuth 2.0 implementation incorporates several security measures: + +* **PKCE (Proof Key for Code Exchange)**: PKCE is a security extension to the Authorization Code Grant flow that prevents authorization code interception attacks. It ensures that only the legitimate client application that initiated the authorization request can exchange the authorization code for an Access Token. Always implement PKCE in your OAuth clients. +* **Rate Limiting**: All API endpoints are subject to rate limiting to prevent abuse and ensure fair usage. Exceeding rate limits will result in HTTP 429 Too Many Requests responses. Implement proper error handling and back-off strategies in your application. +* **User Consent**: The user is always in control. They must explicitly grant consent for your application to access their data and perform actions on their behalf. The consent screen clearly outlines the permissions being requested. +* **Sensitive Scopes**: Scopes like `wallet:send` are considered highly sensitive due to their potential impact. Applications requesting such scopes will undergo additional scrutiny during the approval process and users will be prompted with more prominent warnings during consent. Handle sensitive scopes with extreme care and only request them if absolutely necessary for your application's core functionality. +* **Client Secret Security**: If your application is a confidential client (e.g., a web application with a backend), ensure your `client_secret` is stored securely and never exposed in client-side code or public repositories. + +## 7. Service Worker for Notifications + +The `oauth-service-worker.js` is a crucial component for enabling real-time, asynchronous notifications related to payments and other events within the Stacker News ecosystem. This service worker operates in the background, allowing your application to receive updates even when the user is not actively browsing your site. + +### Purpose + +The service worker facilitates: + +* **Asynchronous Payment Notifications**: It allows Stacker News to push notifications to your application when a payment is approved, an invoice is paid, or a payment completes, without requiring your application to constantly poll the API. +* **Improved User Experience**: Users receive timely updates on their transactions, enhancing the overall experience of applications integrated with the Stacker News wallet. + +### Event Types + +The service worker can dispatch various event types to your application: + +* `oauth_payment_approval_required`: Dispatched when a payment initiated by your application requires user approval (e.g., for large amounts or sensitive transactions). +* `oauth_invoice_paid`: Dispatched when an invoice created by your application has been successfully paid by another user. +* `oauth_payment_completed`: Dispatched when a payment initiated by your application has been successfully completed. + +Your application should register the service worker and implement event listeners to handle these notifications appropriately. + +## 8. Admin Approval (for Stacker News Admins) + +For Stacker News administrators, there is a dedicated process for reviewing and approving new OAuth applications. This ensures that applications integrating with the platform adhere to security best practices and provide a positive user experience. + +### Process + +New OAuth applications submitted by developers are typically reviewed by Stacker News administrators. This review process may involve: + +* Verifying the provided application information (name, description, URLs). +* Assessing the requested scopes and their necessity for the application's stated functionality. +* Checking for adherence to security guidelines. + +### UI Location + +Stacker News administrators can manage and approve OAuth applications via the admin interface at `/admin/oauth-applications`. This interface provides tools to: + +* View pending and approved applications. +* Review application details and requested scopes. +* Approve or reject applications. +* Revoke access for existing applications. + +## 9. Examples (Coming Soon) + +This section will provide practical code examples demonstrating how to implement various aspects of the Stacker News OAuth 2.0 API, including: + +* Initiating the authorization flow. +* Exchanging the authorization code for tokens. +* Making authenticated API requests. +* Handling token expiration and renewal. +* Interacting with the Wallet API (e.g., creating invoices, sending payments). +* Implementing service worker notifications. + +Stay tuned for updates to this section! From e800de542247b3ff6497c7b06a88b871389d47f4 Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 28 Jun 2025 13:01:39 +0200 Subject: [PATCH 4/7] feat: real wallet support and imporved docs --- README.md | 4 + api/typeDefs/oauth.js | 19 ++ docs/dev/{oauth-api.md => oauth.md} | 183 ++++++++++++++++-- pages/api/oauth/applications.js | 2 +- pages/api/oauth/applications/[id].js | 5 + pages/api/oauth/wallet/invoices.js | 36 ++-- pages/api/oauth/wallet/send.js | 34 ++-- .../20250624_oauth_support/migration.sql | 28 +-- prisma/schema.prisma | 13 +- worker/paidAction.js | 4 +- 10 files changed, 260 insertions(+), 68 deletions(-) rename docs/dev/{oauth-api.md => oauth.md} (71%) diff --git a/README.md b/README.md index 6ba466410..a2f862bac 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ To add/remove DNS records you can now use `./sndev domains dns`. More on this [h - [Login with Email](#login-with-email) - [Login with Github](#login-with-github) - [Login with Lightning](#login-with-lightning) + - [OAuth Applications](#oauth-applications) - [Enabling web push notifications](#enabling-web-push-notifications) - [Internals](#internals) - [Stack](#stack) @@ -525,3 +526,6 @@ If you found a vulnerability, we would greatly appreciate it if you contact us v # License [MIT](https://choosealicense.com/licenses/mit/) + +## OAuth Applications +For details on how to create and use OAuth applications, refer to the [OAuth documentation](docs/dev/oauth.md). \ No newline at end of file diff --git a/api/typeDefs/oauth.js b/api/typeDefs/oauth.js index a38cea87d..544b5e5d8 100644 --- a/api/typeDefs/oauth.js +++ b/api/typeDefs/oauth.js @@ -25,6 +25,25 @@ export default gql` pkceRequired: Boolean! } + type OAuthWalletTransaction { + id: ID! + createdAt: Date! + updatedAt: Date! + userId: ID! + applicationId: ID! + accessTokenId: ID! + bolt11: String! + amountMsats: BigInt! + description: String + metadata: JSONObject + status: String! + approved: Boolean + approvedAt: Date + expiresAt: Date! + invoiceId: ID + withdrawalId: ID + } + extend type Query { oAuthApplications: [OAuthApplication!]! oAuthApplication(id: ID!): OAuthApplication diff --git a/docs/dev/oauth-api.md b/docs/dev/oauth.md similarity index 71% rename from docs/dev/oauth-api.md rename to docs/dev/oauth.md index d75c77883..6bdac4240 100644 --- a/docs/dev/oauth-api.md +++ b/docs/dev/oauth.md @@ -289,16 +289,173 @@ Stacker News administrators can manage and approve OAuth applications via the ad * Review application details and requested scopes. * Approve or reject applications. * Revoke access for existing applications. - -## 9. Examples (Coming Soon) - -This section will provide practical code examples demonstrating how to implement various aspects of the Stacker News OAuth 2.0 API, including: - -* Initiating the authorization flow. -* Exchanging the authorization code for tokens. -* Making authenticated API requests. -* Handling token expiration and renewal. -* Interacting with the Wallet API (e.g., creating invoices, sending payments). -* Implementing service worker notifications. - -Stay tuned for updates to this section! +## 9. Examples + +This document provides examples for implementing the OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange (PKCE) for your applications. + +## Configuration + +Before you begin, ensure you have the following configuration details for your OAuth application: + +- `CLIENT_ID`: Your application's client ID. +- `CLIENT_SECRET`: Your application's client secret. **This is a sensitive value and should be kept secure.** +- `REDIRECT_URI`: The URI where the authorization server redirects the user after they have granted or denied permission to your application. This must match one of the redirect URIs configured for your application. +- `AUTHORIZATION_URL`: The endpoint for initiating the authorization flow. (e.g., `http://localhost:3000/api/oauth/authorize`) +- `TOKEN_URL`: The endpoint for exchanging the authorization code for an access token. (e.g., `http://localhost:3000/api/oauth/token`) +- `SCOPES`: A space-separated list of permissions your application is requesting (e.g., `wallet:read profile:read`). + +## Flow Overview + +The Authorization Code Flow with PKCE involves the following steps: + +1. **Generate Code Verifier and Challenge**: Your application generates a cryptographically random `code_verifier` and derives a `code_challenge` from it. +2. **Redirect to Authorization URL**: Your application redirects the user's browser to the `AUTHORIZATION_URL` with the `client_id`, `redirect_uri`, `response_type=code`, `scope`, `code_challenge`, and `code_challenge_method=S256`. +3. **User Authorization**: The user is prompted to authorize your application. +4. **Authorization Code Grant**: If the user approves, the authorization server redirects the user back to your `REDIRECT_URI` with an `authorization_code`. +5. **Exchange Code for Token**: Your application sends a POST request to the `TOKEN_URL` with the `authorization_code`, `redirect_uri`, `client_id`, `client_secret`, and `code_verifier`. +6. **Receive Tokens**: The authorization server validates the `code_verifier` against the `code_challenge` and, if valid, returns `access_token`, `refresh_token`, and `expires_in`. +7. **Access API**: Your application uses the `access_token` to make authenticated requests to protected API resources. + +## Python Example + +The following Python script demonstrates the complete OAuth 2.0 Authorization Code Flow with PKCE. + +```python +import http.server +import socketserver +import urllib.parse +import webbrowser +import requests +import base64 +import hashlib +import os + +# --- Configuration --- +CLIENT_ID = "YOUR_CLIENT_ID" # Replace with your actual client ID +CLIENT_SECRET = "YOUR_CLIENT_SECRET" # Replace with your actual client secret +REDIRECT_URI = "http://localhost:5000/callback" +AUTHORIZATION_URL = "http://localhost:3000/api/oauth/authorize" +TOKEN_URL = "http://localhost:3000/api/oauth/token" +SCOPES = "wallet:read profile:read" # Space-separated list of scopes + +# --- PKCE Functions --- +def generate_code_verifier(): + return base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode('utf-8') + +def generate_code_challenge(code_verifier): + s256 = hashlib.sha256(code_verifier.encode('utf-8')).digest() + return base64.urlsafe_b64encode(s256).rstrip(b'=').decode('utf-8') + +# --- Global variables to store the code and verifier --- +authorization_code = None +pkce_code_verifier = generate_code_verifier() +pkce_code_challenge = generate_code_challenge(pkce_code_verifier) + +class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + global authorization_code + parsed_url = urllib.parse.urlparse(self.path) + query_params = urllib.parse.parse_qs(parsed_url.query) + + if parsed_url.path == "/callback" and "code" in query_params: + authorization_code = query_params["code"][0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b"

Authorization successful!

You can close this tab.

") + print(f"Received authorization code: {authorization_code}") + else: + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not Found") + +def start_local_server(): + PORT = 5000 + with socketserver.TCPServer(("", PORT), OAuthCallbackHandler) as httpd: + print(f"Serving at port {PORT} to catch OAuth redirect...") + httpd.handle_request() + +def main(): + print("--- Starting OAuth 2.0 Authorization Code Flow with PKCE ---") + + # 1. Construct Authorization URL + auth_params = { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "response_type": "code", + "scope": SCOPES, + "code_challenge": pkce_code_challenge, + "code_challenge_method": "S256" + } + auth_query_string = urllib.parse.urlencode(auth_params) + full_authorization_url = f"{AUTHORIZATION_URL}?{auth_query_string}" + + print(f" +Opening authorization URL in your browser. Please approve the request:") + print(full_authorization_url) + webbrowser.open(full_authorization_url) + + # 2. Start local server to catch the redirect + start_local_server() + + if not authorization_code: + print("Error: Did not receive an authorization code.") + return + + # 3. Exchange Authorization Code for Access Token + print(" +Exchanging authorization code for access token...") + token_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": REDIRECT_URI, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code_verifier": pkce_code_verifier + } + + try: + response = requests.post(TOKEN_URL, data=token_data) + response.raise_for_status() # Raise an exception for HTTP errors + token_info = response.json() + + print(" +--- Access Token Response ---") + print(f"Access Token: {token_info.get('access_token')}") + print(f"Token Type: {token_info.get('token_type')}") + print(f"Expires In: {token_info.get('expires_in')} seconds") + print(f"Refresh Token: {token_info.get('refresh_token')}") + print(f"Scope: {token_info.get('scope')}") + + except requests.exceptions.HTTPError as e: + print(f"HTTP Error during token exchange: {e}") + print(f"Response content: {e.response.text}") + except Exception as e: + print(f"An error occurred: {e}") + return + + # 4. Use the Access Token to make an API call + print(" +--- Making API Call ---") + api_url = "http://localhost:3000/api/oauth/wallet/balance" + headers = { + "Authorization": f"Bearer {token_info.get('access_token')}" + } + try: + api_response = requests.get(api_url, headers=headers) + api_response.raise_for_status() + api_data = api_response.json() + print("API call successful!") + print("Response:") + print(api_data) + except requests.exceptions.HTTPError as e: + print(f"HTTP Error during API call: {e}") + print(f"Response content: {e.response.text}") + except Exception as e: + print(f"An error occurred during API call: {e}") + + +if __name__ == "__main__": + # Ensure you have the 'requests' library installed: pip install requests + main() +``` diff --git a/pages/api/oauth/applications.js b/pages/api/oauth/applications.js index 2039cbd96..2f0b2e611 100644 --- a/pages/api/oauth/applications.js +++ b/pages/api/oauth/applications.js @@ -99,7 +99,7 @@ async function createApplication (req, res, session) { clientId, clientSecretHash, redirectUris, - scopes: scopes.map(s => s.replace(':', '_')), // Convert to enum format + scopes: scopes.map(s => s.replace(':', '_')), logoUrl, userId: parseInt(session.user.id), isConfidential: true, diff --git a/pages/api/oauth/applications/[id].js b/pages/api/oauth/applications/[id].js index c9350ee69..10f917a41 100644 --- a/pages/api/oauth/applications/[id].js +++ b/pages/api/oauth/applications/[id].js @@ -68,6 +68,11 @@ async function getApplication (req, res, session, applicationId) { where: { revoked: false } + }, + walletTransactions: { + where: { + status: 'approved' + } } } } diff --git a/pages/api/oauth/wallet/invoices.js b/pages/api/oauth/wallet/invoices.js index 1c92f8628..235617ba0 100644 --- a/pages/api/oauth/wallet/invoices.js +++ b/pages/api/oauth/wallet/invoices.js @@ -1,6 +1,8 @@ import { authenticateOAuth } from '../../../../lib/oauth-auth' import models from '../../../../api/models' import { InvoiceActionType } from '@prisma/client' +import { createSNInvoice, createDbInvoice } from '../../../../api/paidAction' +import lnd from '../../../../api/lnd' export default async function handler (req, res) { if (req.method === 'GET') { @@ -125,7 +127,7 @@ async function createInvoice (req, res) { const expiresAt = new Date(Date.now() + expirySeconds * 1000) // Create invoice request for approval - const invoiceRequest = await models.oAuthWalletInvoiceRequest.create({ + const invoiceRequest = await models.oAuthWalletTransaction.create({ data: { userId: user.id, applicationId: accessToken.applicationId, @@ -144,20 +146,28 @@ async function createInvoice (req, res) { if (requestedAmountMsats <= autoApproveThreshold) { // Auto-approve and create actual invoice - const invoice = await models.invoice.create({ - data: { - userId: user.id, - msatsRequested: requestedAmountMsats, - desc: description || 'Invoice via OAuth app', - actionType: InvoiceActionType.RECEIVE, - expiresAt, - // Note: In a real implementation, you'd generate the actual bolt11 here - // using the user's configured receive wallet - bolt11: `lnbc${Math.floor(Number(requestedAmountMsats) / 1000)}...` // Placeholder - } + const invoiceArgs = await createSNInvoice(InvoiceActionType.RECEIVE, { }, { + me: user, + lnd, + cost: requestedAmountMsats, + optimistic: true, + models, + description: description || 'Invoice via OAuth app' + }) + + const invoice = await createDbInvoice(InvoiceActionType.RECEIVE, { }, { + me: user, + models, + tx: models, // Pass models as tx for direct use + cost: requestedAmountMsats, + optimistic: true, + invoiceArgs, + actionId: null, // No specific action ID for a simple receive invoice + paymentAttempt: 0, + predecessorId: null }) - await models.oAuthWalletInvoiceRequest.update({ + await models.oAuthWalletTransaction.update({ where: { id: invoiceRequest.id }, data: { status: 'approved', diff --git a/pages/api/oauth/wallet/send.js b/pages/api/oauth/wallet/send.js index 6a82ddb16..281a49f40 100644 --- a/pages/api/oauth/wallet/send.js +++ b/pages/api/oauth/wallet/send.js @@ -57,7 +57,7 @@ export default async function handler (req, res) { } // Create a payment request that requires user approval - const paymentRequest = await models.oAuthWalletInvoiceRequest.create({ + const paymentRequest = await models.oAuthWalletTransaction.create({ data: { userId: user.id, applicationId: accessToken.applicationId, @@ -82,7 +82,7 @@ export default async function handler (req, res) { // In a real implementation, this would actually send the payment const withdrawal = await createWithdrawal(null, { invoice: bolt11, maxFee: maxFeeMsats ? Number(maxFeeMsats) : undefined }, { me: user, models, lnd }) - await models.oAuthWalletInvoiceRequest.update({ + await models.oAuthWalletTransaction.update({ where: { id: paymentRequest.id }, data: { status: 'approved', @@ -93,28 +93,18 @@ export default async function handler (req, res) { }) return res.status(200).json({ - payment_id: paymentRequest.id, - status: withdrawal.status === 'CONFIRMED' ? 'complete' : 'pending', - amount_msats: amountMsats.toString(), - amount_sats: Math.floor(Number(amountMsats) / 1000), - destination: parsedPaymentRequest.destination, - description: parsedPaymentRequest.description, - approved_at: new Date().toISOString(), - withdrawal_id: withdrawal.id - }) - } else { - // Requires manual approval - return res.status(202).json({ - payment_id: paymentRequest.id, - status: 'pending_approval', - amount_msats: amountMsats.toString(), - amount_sats: Math.floor(Number(amountMsats) / 1000), - destination: parsedPaymentRequest.destination, - description: parsedPaymentRequest.description, - expires_at: paymentRequest.expiresAt.toISOString(), - approval_url: `/oauth/approve-payment/${paymentRequest.id}` + status: 'OK', + approved: true, + payment_request_id: paymentRequest.id }) } + + return res.status(200).json({ + status: 'OK', + approved: false, + message: 'Payment requires user approval', + payment_request_id: paymentRequest.id + }) } catch (error) { console.error('Error in OAuth wallet send:', error) return res.status(500).json({ error: 'Internal server error' }) diff --git a/prisma/migrations/20250624_oauth_support/migration.sql b/prisma/migrations/20250624_oauth_support/migration.sql index 239191510..189f9e1b8 100644 --- a/prisma/migrations/20250624_oauth_support/migration.sql +++ b/prisma/migrations/20250624_oauth_support/migration.sql @@ -114,7 +114,7 @@ CREATE TABLE "OAuthAuthorizationGrant" ( ); -- CreateTable -CREATE TABLE "OAuthWalletInvoiceRequest" ( +CREATE TABLE "OAuthWalletTransaction" ( "id" SERIAL NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -130,8 +130,9 @@ CREATE TABLE "OAuthWalletInvoiceRequest" ( "approved_at" TIMESTAMP(3), "expires_at" TIMESTAMP(3) NOT NULL, "invoice_id" INTEGER, + "withdrawal_id" INTEGER, - CONSTRAINT "OAuthWalletInvoiceRequest_pkey" PRIMARY KEY ("id") + CONSTRAINT "OAuthWalletTransaction_pkey" PRIMARY KEY ("id") ); -- CreateTable @@ -218,19 +219,19 @@ CREATE INDEX "OAuthAuthorizationGrant_application_id_idx" ON "OAuthAuthorization CREATE INDEX "OAuthAuthorizationGrant_revoked_idx" ON "OAuthAuthorizationGrant"("revoked"); -- CreateIndex -CREATE INDEX "OAuthWalletInvoiceRequest_user_id_idx" ON "OAuthWalletInvoiceRequest"("user_id"); +CREATE INDEX "OAuthWalletTransaction_user_id_idx" ON "OAuthWalletTransaction"("user_id"); -- CreateIndex -CREATE INDEX "OAuthWalletInvoiceRequest_application_id_idx" ON "OAuthWalletInvoiceRequest"("application_id"); +CREATE INDEX "OAuthWalletTransaction_application_id_idx" ON "OAuthWalletTransaction"("application_id"); -- CreateIndex -CREATE INDEX "OAuthWalletInvoiceRequest_access_token_id_idx" ON "OAuthWalletInvoiceRequest"("access_token_id"); +CREATE INDEX "OAuthWalletTransaction_access_token_id_idx" ON "OAuthWalletTransaction"("access_token_id"); -- CreateIndex -CREATE INDEX "OAuthWalletInvoiceRequest_status_idx" ON "OAuthWalletInvoiceRequest"("status"); +CREATE INDEX "OAuthWalletTransaction_status_idx" ON "OAuthWalletTransaction"("status"); -- CreateIndex -CREATE INDEX "OAuthWalletInvoiceRequest_expires_at_idx" ON "OAuthWalletInvoiceRequest"("expires_at"); +CREATE INDEX "OAuthWalletTransaction_expires_at_idx" ON "OAuthWalletTransaction"("expires_at"); -- CreateIndex CREATE INDEX "OAuthApiUsage_application_id_idx" ON "OAuthApiUsage"("application_id"); @@ -275,16 +276,19 @@ ALTER TABLE "OAuthAuthorizationGrant" ADD CONSTRAINT "OAuthAuthorizationGrant_us ALTER TABLE "OAuthAuthorizationGrant" ADD CONSTRAINT "OAuthAuthorizationGrant_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OAuthWalletTransaction" ADD CONSTRAINT "OAuthWalletTransaction_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OAuthWalletTransaction" ADD CONSTRAINT "OAuthWalletTransaction_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_access_token_id_fkey" FOREIGN KEY ("access_token_id") REFERENCES "OAuthAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OAuthWalletTransaction" ADD CONSTRAINT "OAuthWalletTransaction_access_token_id_fkey" FOREIGN KEY ("access_token_id") REFERENCES "OAuthAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "OAuthWalletInvoiceRequest" ADD CONSTRAINT "OAuthWalletInvoiceRequest_invoice_id_fkey" FOREIGN KEY ("invoice_id") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OAuthWalletTransaction" ADD CONSTRAINT "OAuthWalletTransaction_invoice_id_fkey" FOREIGN KEY ("invoice_id") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthWalletTransaction" ADD CONSTRAINT "OAuthWalletTransaction_withdrawal_id_fkey" FOREIGN KEY ("withdrawal_id") REFERENCES "Withdrawl"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; @@ -293,4 +297,4 @@ ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_application_id_fkey" F ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_access_token_id_fkey" FOREIGN KEY ("access_token_id") REFERENCES "OAuthAccessToken"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file +ALTER TABLE "OAuthApiUsage" ADD CONSTRAINT "OAuthApiUsage_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cc65451ff..4260bb9ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -156,7 +156,7 @@ model User { OAuthAccessTokens OAuthAccessToken[] OAuthRefreshTokens OAuthRefreshToken[] OAuthAuthorizationGrants OAuthAuthorizationGrant[] - OAuthWalletInvoiceRequests OAuthWalletInvoiceRequest[] + OAuthWalletTransactions OAuthWalletTransaction[] OAuthApiUsage OAuthApiUsage[] @@index([photoId]) @@ -1000,7 +1000,7 @@ model Invoice { PollVote PollVote[] PollBlindVote PollBlindVote[] WalletLog WalletLog[] - OAuthWalletInvoiceRequests OAuthWalletInvoiceRequest[] + OAuthWalletTransactions OAuthWalletTransaction[] @@index([createdAt], map: "Invoice.created_at_index") @@index([userId], map: "Invoice.userId_index") @@ -1079,6 +1079,7 @@ model Withdrawl { wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) invoiceForward InvoiceForward? WalletLog WalletLog[] + OAuthWalletTransaction OAuthWalletTransaction[] @@index([createdAt], map: "Withdrawl.created_at_index") @@index([userId], map: "Withdrawl.userId_index") @@ -1357,7 +1358,7 @@ model OAuthApplication { accessTokens OAuthAccessToken[] refreshTokens OAuthRefreshToken[] authorizationGrants OAuthAuthorizationGrant[] - walletInvoiceRequests OAuthWalletInvoiceRequest[] + walletInvoiceRequests OAuthWalletTransaction[] apiUsage OAuthApiUsage[] @@index([userId]) @@ -1404,7 +1405,7 @@ model OAuthAccessToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) refreshToken OAuthRefreshToken? - walletInvoiceRequests OAuthWalletInvoiceRequest[] + walletInvoiceRequests OAuthWalletTransaction[] apiUsage OAuthApiUsage[] @@index([userId]) @@ -1455,7 +1456,7 @@ model OAuthAuthorizationGrant { @@index([revoked]) } -model OAuthWalletInvoiceRequest { +model OAuthWalletTransaction { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @@ -1471,11 +1472,13 @@ model OAuthWalletInvoiceRequest { approvedAt DateTime? @map("approved_at") expiresAt DateTime @map("expires_at") invoiceId Int? @map("invoice_id") + withdrawalId Int? @map("withdrawal_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) accessToken OAuthAccessToken @relation(fields: [accessTokenId], references: [id], onDelete: Cascade) invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id], onDelete: SetNull) @@index([userId]) @@index([applicationId]) diff --git a/worker/paidAction.js b/worker/paidAction.js index de63d91d0..5ebc144b2 100644 --- a/worker/paidAction.js +++ b/worker/paidAction.js @@ -173,8 +173,8 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))` // If it's an OAuth invoice, send a push notification - if (dbInvoice.actionType === 'RECEIVE' && dbInvoice.oAuthWalletInvoiceRequest) { - const oAuthRequest = await tx.oAuthWalletInvoiceRequest.findUnique({ + if (dbInvoice.actionType === 'RECEIVE' && dbInvoice.oAuthWalletTransaction) { + const oAuthRequest = await tx.oAuthWalletTransaction.findUnique({ where: { invoiceId: dbInvoice.id }, include: { oauthApplication: true } }) From b46a75d75e9b3c1cba4cd44642005d26964e7853 Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 28 Jun 2025 14:54:03 +0200 Subject: [PATCH 5/7] fix: add BigInt scalar type --- api/typeDefs/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 70b804c6c..97fedd458 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -38,6 +38,7 @@ const common = gql` scalar JSONObject scalar Date scalar Limit + scalar BigInt ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, From cc418bad91aa36691e0e3e7ecd498730b9bfe63d Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 28 Jun 2025 14:54:10 +0200 Subject: [PATCH 6/7] feat: popup to show client secret after creating a new OAuth application --- pages/settings/oauth-applications.js | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pages/settings/oauth-applications.js b/pages/settings/oauth-applications.js index 503ec1d18..d65e9ca65 100644 --- a/pages/settings/oauth-applications.js +++ b/pages/settings/oauth-applications.js @@ -15,6 +15,7 @@ export default function OAuthApplications () { const [error, setError] = useState('') const [showModal, setShowModal] = useState(false) const [editingApp, setEditingApp] = useState(null) + const [newApp, setNewApp] = useState(null) const [formData, setFormData] = useState({ name: '', description: '', @@ -83,6 +84,11 @@ export default function OAuthApplications () { throw new Error(errorData.error || 'Failed to save application') } + if (!editingApp) { + const newApplication = await response.json() + setNewApp(newApplication) + } + await fetchApplications() setShowModal(false) resetForm() @@ -453,6 +459,30 @@ export default function OAuthApplications () { + + setNewApp(null)}> + + Application Created + + + + Your client secret is shown below. Make sure to copy it now. You won't be able to see it again. + + + Client ID + + + + Client Secret + + + + + + + From e90df4dcc385fcefe9bb1ca3c0e8a6ddeb9f9d40 Mon Sep 17 00:00:00 2001 From: m0wer Date: Sun, 29 Jun 2025 22:18:13 +0200 Subject: [PATCH 7/7] feat: simplify OAuth scopes, add userinfo endpoint, and enhance wallet API --- api/paidAction/index.js | 4 +- api/resolvers/wallet.js | 45 ++- api/typeDefs/wallet.js | 1 + docs/dev/oauth.md | 311 +++++++++++------- lib/oauth-scopes.js | 75 ----- pages/api/oauth/applications.js | 5 +- pages/api/oauth/applications/[id].js | 5 +- pages/api/oauth/authorize.js | 2 +- pages/api/oauth/userinfo.js | 43 +++ pages/api/oauth/wallet/invoices.js | 4 + pages/api/oauth/wallet/send.js | 20 +- pages/oauth/consent.js | 25 +- pages/settings/oauth-applications.js | 7 +- .../20250624_oauth_support/migration.sql | 7 +- prisma/schema.prisma | 5 - 15 files changed, 308 insertions(+), 251 deletions(-) create mode 100644 pages/api/oauth/userinfo.js diff --git a/api/paidAction/index.js b/api/paidAction/index.js index cd627987d..09d2038d0 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -405,7 +405,7 @@ export class NonInvoiceablePeerError extends Error { // we seperate the invoice creation into two functions because // because if lnd is slow, it'll timeout the interactive tx -async function createSNInvoice (actionType, args, context) { +export async function createSNInvoice (actionType, args, context) { const { me, lnd, cost, optimistic } = context const action = paidActions[actionType] const createLNDInvoice = optimistic ? createInvoice : createHodlInvoice @@ -427,7 +427,7 @@ async function createSNInvoice (actionType, args, context) { return { bolt11: invoice.request, preimage: invoice.secret } } -async function createDbInvoice (actionType, args, context) { +export async function createDbInvoice (actionType, args, context) { const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c97eac79d..d2750b5fa 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -1,5 +1,5 @@ import { - getInvoice as getInvoiceFromLnd, deletePayment, getPayment, + getInvoice as getInvoiceFromLnd, deletePayment, getPayment, createInvoice, parsePaymentRequest } from 'ln-service' import crypto, { timingSafeEqual } from 'crypto' @@ -474,6 +474,49 @@ const resolvers = { __resolveType: invoiceOrDirect => invoiceOrDirect.__resolveType }, Mutation: { + createInvoice: async (parent, { amount, expireMins, hodlInvoice, description, hash, hmac }, { me, models, lnd }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + if (hodlInvoice && (!hash || !hmac)) { + throw new GqlInputError('hash and hmac are required for hodl invoices') + } + + if (hodlInvoice && hash && hmac) { + verifyHmac(hash, hmac) + } + + const descriptionHash = hodlInvoice ? crypto.createHash('sha256').update(description).digest('hex') : undefined + const msats = amount * 1000 + try { + const invoice = await createInvoice({ + description: hodlInvoice ? undefined : description, + description_hash: descriptionHash, + expires_at: datePivot(new Date(), { minutes: expireMins || 1440 }), + mtokens: msats, + lnd, + is_including_private_channels: true + }) + + const inv = await models.invoice.create({ + data: { + hash: invoice.id, + bolt11: invoice.request, + expiresAt: invoice.expires_at, + msatsRequested: msats, + userId: me.id, + description, + private: !!hodlInvoice + } + }) + + return inv + } catch (error) { + console.log(error) + throw new Error('Failed to create invoice') + } + }, createWithdrawl: createWithdrawal, sendToLnAddr, cancelInvoice: async (parent, { hash, hmac, userCancel }, { me, models, lnd, boss }) => { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index b003ba428..f4bde60e0 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -74,6 +74,7 @@ const typeDefs = ` } extend type Mutation { + createInvoice(amount: Int!, expireMins: Int, hodlInvoice: Boolean, description: String, hash: String, hmac: String): Invoice! createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl! sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl! cancelInvoice(hash: String!, hmac: String, userCancel: Boolean): Invoice! diff --git a/docs/dev/oauth.md b/docs/dev/oauth.md index 6bdac4240..ae673ac48 100644 --- a/docs/dev/oauth.md +++ b/docs/dev/oauth.md @@ -20,7 +20,7 @@ The Stacker News OAuth implementation includes: * **Authorization Code Grant with PKCE**: A secure and widely recommended OAuth 2.0 flow that prevents authorization code interception attacks. * **Application Management UI**: Developers can register and manage their OAuth applications directly within the Stacker News platform via `/settings/oauth-applications`. * **User Consent Screen**: Users are presented with a clear and concise consent screen, detailing the permissions an application is requesting before they grant access. -* **Granular Scopes**: A comprehensive set of scopes allows applications to request only the necessary permissions, adhering to the principle of least privilege. +* **Granular Scopes**: Defines a set of scopes for basic user information (`read`, `profile:read`) and wallet permissions (`wallet:read`, `wallet:send`, `wallet:receive`). * **Secure Wallet API**: A dedicated set of API endpoints for secure interaction with user wallets, enabling payments and invoice management. * **Token Management**: Robust mechanisms for issuing, refreshing, and revoking access and refresh tokens. * **Rate Limiting**: Implemented to ensure fair usage and protect the API from abuse. @@ -92,9 +92,9 @@ This endpoint is used by your application to exchange the authorization code for { "access_token": "YOUR_ACCESS_TOKEN", "token_type": "Bearer", - "expires_in": 3600, // seconds + "expires_in": 7200, "refresh_token": "YOUR_REFRESH_TOKEN", - "scope": "read wallet:read" + "scope": "read profile:read wallet:read" } ``` @@ -117,19 +117,10 @@ Authorization: Bearer YOUR_ACCESS_TOKEN ```json { - "id": 123, - "name": "satoshi", - "created_at": "2023-01-01T12:00:00.000Z", - "sats": 100000, - "free_posts": 5, - "free_comments": 10, - "streak": 7, - "photoId": 456, - "hideCowboy": false, - "lastPost": "2023-06-20T10:00:00.000Z", - "lastComment": "2023-06-20T11:00:00.000Z", - "lastMute": null, - "bio": "A brief bio of the user." + "id": 21858, + "name": "fac7e6077a", + "created_at": "2025-06-28T20:32:43.569Z", + "photo_id": null } ``` @@ -149,7 +140,10 @@ These endpoints allow your application to interact with the user's Stacker News ```json { - "balance": 100000 // in sats + "balance_msats": "9900000", + "balance_sats": 9900, + "stacked_msats": "70000000", + "stacked_sats": 70000 } ``` @@ -172,9 +166,16 @@ These endpoints allow your application to interact with the user's Stacker News ```json { - "bolt11": "lnbc...", - "hash": "...", - "expires_at": "..." + "id": 159690, + "hash": "26301e208c90a920faa886de277233c644328a0d34191e210e18b0a3556b0492", + "bolt11": "lnbcrt1u1p5xzchrpp5yccpugyvjz5jp74gsm0zwu3ncezr9zsdxsv3uggwrzc2x4ttqjfqdp52d8r5grxv93nwefkxqmnwcfqwfjkxetfwejhxgp3xqczqumpw3escqzzsxqzjcsp5x35r6l5e9xax486djq69rev026h5fd08ttnw605qkq3jeaqxts9q9qxpqysgqqlf7y35rsm5uzq209ma58wd7nj7zgfn33gd7hs45d6ppwf0eer2yemfdlsvfl2uduv49rt42e9g95xyduk7z9tc6xdx96tjn4pk5nscqvz77vg", + "amount_requested_msats": "100000", + "amount_requested_sats": 100, + "description": "Test Invoice", + "status": "pending", + "expires_at": "2025-06-29T16:09:31.000Z", + "created_at": "2025-06-29T15:59:31.236Z", + "request_id": 16 } ``` @@ -189,14 +190,15 @@ These endpoints allow your application to interact with the user's Stacker News **Parameters:** * `bolt11` (required): The Bolt11 invoice string to pay. -* `max_fee_msats` (optional): The maximum fee in millisatoshis that the user is willing to pay for the transaction. +* `max_fee_sats` (optional): The maximum fee in satoshis that the user is willing to pay for the transaction. **Successful Response (Example):** ```json { - "success": true, - "preimage": "..." + "status": "OK", + "approved": true, + "payment_request_id": 17 } ``` @@ -205,15 +207,10 @@ These endpoints allow your application to interact with the user's Stacker News Scopes define the permissions your application requests from the user. When a user authorizes your application, they are presented with a consent screen detailing these scopes. It's crucial to request only the scopes necessary for your application's functionality, adhering to the principle of least privilege. * `read`: Read-only access to public user data, such as username, creation date, and public profile information. -* `write:posts`: Allows your application to create new posts and edit existing posts on behalf of the user. -* `write:comments`: Allows your application to create new comments and edit existing comments on behalf of the user. * `wallet:read`: Provides read-only access to the user's wallet balance and invoice history. Your application cannot initiate payments with this scope. * `wallet:send`: Grants permission to send payments from the user's wallet. This is a highly sensitive scope and requires explicit user approval. * `wallet:receive`: Grants permission to create invoices for receiving payments into the user's wallet. -* `profile:read`: Provides read-only access to the user's private profile information, such as email address (if available and consented). -* `profile:write`: Allows your application to modify the user's profile information. -* `notifications:read`: Provides read-only access to the user's notifications. -* `notifications:write`: Allows your application to manage the user's notification settings. +* `profile:read`: Provides read-only access to the user's profile information, such as name and photo. ## 5. Token Management @@ -291,34 +288,21 @@ Stacker News administrators can manage and approve OAuth applications via the ad * Revoke access for existing applications. ## 9. Examples -This document provides examples for implementing the OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange (PKCE) for your applications. +This section provides a comprehensive Python example for implementing the OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange (PKCE). This script demonstrates how to obtain an access token and use it to interact with various protected API endpoints. -## Configuration +### Python Example -Before you begin, ensure you have the following configuration details for your OAuth application: +The following Python script demonstrates the complete flow, including: +- Generating PKCE codes. +- Initiating the authorization flow. +- Handling the redirect and exchanging the authorization code for an access token. +- Making authenticated API calls to fetch user info, check wallet balance, create invoices, and send payments. -- `CLIENT_ID`: Your application's client ID. -- `CLIENT_SECRET`: Your application's client secret. **This is a sensitive value and should be kept secure.** -- `REDIRECT_URI`: The URI where the authorization server redirects the user after they have granted or denied permission to your application. This must match one of the redirect URIs configured for your application. -- `AUTHORIZATION_URL`: The endpoint for initiating the authorization flow. (e.g., `http://localhost:3000/api/oauth/authorize`) -- `TOKEN_URL`: The endpoint for exchanging the authorization code for an access token. (e.g., `http://localhost:3000/api/oauth/token`) -- `SCOPES`: A space-separated list of permissions your application is requesting (e.g., `wallet:read profile:read`). - -## Flow Overview - -The Authorization Code Flow with PKCE involves the following steps: - -1. **Generate Code Verifier and Challenge**: Your application generates a cryptographically random `code_verifier` and derives a `code_challenge` from it. -2. **Redirect to Authorization URL**: Your application redirects the user's browser to the `AUTHORIZATION_URL` with the `client_id`, `redirect_uri`, `response_type=code`, `scope`, `code_challenge`, and `code_challenge_method=S256`. -3. **User Authorization**: The user is prompted to authorize your application. -4. **Authorization Code Grant**: If the user approves, the authorization server redirects the user back to your `REDIRECT_URI` with an `authorization_code`. -5. **Exchange Code for Token**: Your application sends a POST request to the `TOKEN_URL` with the `authorization_code`, `redirect_uri`, `client_id`, `client_secret`, and `code_verifier`. -6. **Receive Tokens**: The authorization server validates the `code_verifier` against the `code_challenge` and, if valid, returns `access_token`, `refresh_token`, and `expires_in`. -7. **Access API**: Your application uses the `access_token` to make authenticated requests to protected API resources. - -## Python Example - -The following Python script demonstrates the complete OAuth 2.0 Authorization Code Flow with PKCE. +To run this example: +1. Save the code as a Python file (e.g., `oauth_example.py`). +2. Install the `requests` library: `pip install requests`. +3. Replace the placeholder values in the "Configuration" section with your application's actual credentials. +4. Run the script from your terminal: `python oauth_example.py`. ```python import http.server @@ -329,133 +313,208 @@ import requests import base64 import hashlib import os +import json # --- Configuration --- -CLIENT_ID = "YOUR_CLIENT_ID" # Replace with your actual client ID -CLIENT_SECRET = "YOUR_CLIENT_SECRET" # Replace with your actual client secret +# IMPORTANT: Replace with your actual client credentials and settings. +CLIENT_ID = "YOUR_CLIENT_ID" +CLIENT_SECRET = "YOUR_CLIENT_SECRET" # Keep this secret! REDIRECT_URI = "http://localhost:5000/callback" -AUTHORIZATION_URL = "http://localhost:3000/api/oauth/authorize" -TOKEN_URL = "http://localhost:3000/api/oauth/token" -SCOPES = "wallet:read profile:read" # Space-separated list of scopes - -# --- PKCE Functions --- +# Adjust the base URL to match the Stacker News instance you are targeting (e.g., https://stacker.news) +SN_BASE_URL = "http://localhost:3000" +AUTHORIZATION_URL = f"{SN_BASE_URL}/api/oauth/authorize" +TOKEN_URL = f"{SN_BASE_URL}/api/oauth/token" +# Define the scopes your application needs. +SCOPES = "profile:read wallet:read wallet:receive wallet:send" + +# --- PKCE Helper Functions --- def generate_code_verifier(): + """Generate a cryptographically random string for PKCE.""" return base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode('utf-8') def generate_code_challenge(code_verifier): + """Generate a SHA256 code challenge from the verifier for PKCE.""" s256 = hashlib.sha256(code_verifier.encode('utf-8')).digest() return base64.urlsafe_b64encode(s256).rstrip(b'=').decode('utf-8') -# --- Global variables to store the code and verifier --- +# --- Global variables to hold OAuth data during the flow --- authorization_code = None -pkce_code_verifier = generate_code_verifier() -pkce_code_challenge = generate_code_challenge(pkce_code_verifier) +oauth_error = None class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler): + """A simple HTTP server to catch the OAuth redirect.""" def do_GET(self): - global authorization_code + global authorization_code, oauth_error parsed_url = urllib.parse.urlparse(self.path) query_params = urllib.parse.parse_qs(parsed_url.query) - if parsed_url.path == "/callback" and "code" in query_params: + if "code" in query_params: authorization_code = query_params["code"][0] - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write(b"

Authorization successful!

You can close this tab.

") + message = "

Authorization successful!

You can close this tab.

" print(f"Received authorization code: {authorization_code}") + elif "error" in query_params: + error_details = { + "error": query_params.get("error", ["unknown"])[0], + "description": query_params.get("error_description", [""])[0] + } + oauth_error = json.dumps(error_details) + message = f"

Authorization failed!

Error: {error_details['error']}

Description: {error_details['description']}

" + print(f"Received OAuth error: {oauth_error}") else: self.send_response(404) self.end_headers() self.wfile.write(b"Not Found") + return + + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(message.encode('utf-8')) def start_local_server(): + """Starts a local server to handle the OAuth redirect and returns when the request is handled.""" PORT = 5000 - with socketserver.TCPServer(("", PORT), OAuthCallbackHandler) as httpd: + # Use a server that allows address reuse to avoid issues on quick restarts + class ReusableTCPServer(socketserver.TCPServer): + allow_reuse_address = True + + with ReusableTCPServer(("", PORT), OAuthCallbackHandler) as httpd: print(f"Serving at port {PORT} to catch OAuth redirect...") - httpd.handle_request() + httpd.handle_request() # This will block until one request is handled + print(f"Local server on port {PORT} shut down.") + +def exchange_code_for_token(code, verifier): + """Exchanges the authorization code for an access token.""" + print(" +Exchanging authorization code for access token...") + token_data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code_verifier": verifier + } + try: + response = requests.post(TOKEN_URL, data=token_data) + response.raise_for_status() + token_info = response.json() + print(" +--- Access Token Response ---") + print(json.dumps(token_info, indent=2)) + return token_info.get('access_token') + except requests.exceptions.HTTPError as e: + print(f"HTTP Error during token exchange: {e}") + print(f"Response content: {e.response.text}") + return None + +def make_api_call(access_token, endpoint, method='GET', json_data=None): + """Makes an authenticated API call to a Stacker News OAuth endpoint.""" + print(f" +--- Making API Call to {endpoint} ---") + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + url = f"{SN_BASE_URL}/api/oauth/{endpoint}" + + try: + if method == 'GET': + response = requests.get(url, headers=headers) + elif method == 'POST': + response = requests.post(url, headers=headers, json=json_data) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + response.raise_for_status() + api_response = response.json() + print(f"Status Code: {response.status_code}") + print("Response:") + print(json.dumps(api_response, indent=2)) + return api_response + except requests.exceptions.HTTPError as e: + print(f"HTTP Error during API call to {endpoint}: {e}") + print(f"Response content: {e.response.text}") + return None def main(): - print("--- Starting OAuth 2.0 Authorization Code Flow with PKCE ---") + """Main function to run the OAuth flow and demonstrate API calls.""" + print("====== Starting Stacker News OAuth 2.0 Example Flow ======") + + # 1. Generate PKCE codes + code_verifier = generate_code_verifier() + code_challenge = generate_code_challenge(code_verifier) + print(f"Generated PKCE Code Verifier (secret): {code_verifier}") + print(f"Generated PKCE Code Challenge: {code_challenge}") - # 1. Construct Authorization URL + # 2. Construct the authorization URL and open it in the browser auth_params = { "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "response_type": "code", "scope": SCOPES, - "code_challenge": pkce_code_challenge, + "code_challenge": code_challenge, "code_challenge_method": "S256" } - auth_query_string = urllib.parse.urlencode(auth_params) - full_authorization_url = f"{AUTHORIZATION_URL}?{auth_query_string}" - + full_authorization_url = f"{AUTHORIZATION_URL}?{urllib.parse.urlencode(auth_params)}" print(f" Opening authorization URL in your browser. Please approve the request:") print(full_authorization_url) webbrowser.open(full_authorization_url) - # 2. Start local server to catch the redirect + # 3. Start the local server to catch the redirect with the authorization code start_local_server() + if oauth_error: + print(" +OAuth flow failed. Exiting.") + return if not authorization_code: - print("Error: Did not receive an authorization code.") + print(" +Did not receive an authorization code. Exiting.") return - # 3. Exchange Authorization Code for Access Token - print(" -Exchanging authorization code for access token...") - token_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": REDIRECT_URI, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "code_verifier": pkce_code_verifier - } - - try: - response = requests.post(TOKEN_URL, data=token_data) - response.raise_for_status() # Raise an exception for HTTP errors - token_info = response.json() - + # 4. Exchange the authorization code for an access token + access_token = exchange_code_for_token(authorization_code, code_verifier) + if not access_token: print(" ---- Access Token Response ---") - print(f"Access Token: {token_info.get('access_token')}") - print(f"Token Type: {token_info.get('token_type')}") - print(f"Expires In: {token_info.get('expires_in')} seconds") - print(f"Refresh Token: {token_info.get('refresh_token')}") - print(f"Scope: {token_info.get('scope')}") - - except requests.exceptions.HTTPError as e: - print(f"HTTP Error during token exchange: {e}") - print(f"Response content: {e.response.text}") - except Exception as e: - print(f"An error occurred: {e}") +Failed to obtain access token. Exiting.") return - # 4. Use the Access Token to make an API call + # 5. Use the access token to make various API calls print(" ---- Making API Call ---") - api_url = "http://localhost:3000/api/oauth/wallet/balance" - headers = { - "Authorization": f"Bearer {token_info.get('access_token')}" +====== Demonstrating API Calls with Access Token ======") + + # Get user profile info (requires 'profile:read' scope) + make_api_call(access_token, "userinfo") + + # Get wallet balance (requires 'wallet:read' scope) + make_api_call(access_token, "wallet/balance") + + # Create an invoice to receive sats (requires 'wallet:receive' scope) + invoice_details = { + "amount_sats": 100, + "description": "Test invoice from my awesome app" } - try: - api_response = requests.get(api_url, headers=headers) - api_response.raise_for_status() - api_data = api_response.json() - print("API call successful!") - print("Response:") - print(api_data) - except requests.exceptions.HTTPError as e: - print(f"HTTP Error during API call: {e}") - print(f"Response content: {e.response.text}") - except Exception as e: - print(f"An error occurred during API call: {e}") + created_invoice = make_api_call(access_token, "wallet/invoices", method='POST', json_data=invoice_details) + + # Send sats (requires 'wallet:send' scope) + # NOTE: You need a valid BOLT11 invoice from a different wallet to pay. + # The invoice below is a placeholder and will not work. + invoice_to_pay = "lnbc..." # <-- REPLACE WITH A REAL INVOICE + payment_details = { + "bolt11": invoice_to_pay, + "max_fee_sats": 10 # Optional: set a max fee in sats + } + # Uncomment the line below to attempt a payment + # make_api_call(access_token, "wallet/send", method='POST', json_data=payment_details) + print(" +--- Skipping wallet:send call ---") + print("To test sending, replace the placeholder invoice in the script and uncomment the API call.") if __name__ == "__main__": # Ensure you have the 'requests' library installed: pip install requests main() ``` + diff --git a/lib/oauth-scopes.js b/lib/oauth-scopes.js index f2f5e9326..25f13c693 100644 --- a/lib/oauth-scopes.js +++ b/lib/oauth-scopes.js @@ -10,22 +10,6 @@ export const OAUTH_SCOPES = { riskLevel: 'low' }, - // Content creation - 'write:posts': { - name: 'Create Posts', - description: 'Create and edit posts on your behalf', - icon: '✍️', - category: 'content', - riskLevel: 'medium' - }, - 'write:comments': { - name: 'Create Comments', - description: 'Create and edit comments on your behalf', - icon: '💬', - category: 'content', - riskLevel: 'medium' - }, - // Wallet access (high risk) 'wallet:read': { name: 'Read Wallet', @@ -57,29 +41,6 @@ export const OAUTH_SCOPES = { icon: '👤', category: 'profile', riskLevel: 'low' - }, - 'profile:write': { - name: 'Update Profile', - description: 'Update your profile information and settings', - icon: '✏️', - category: 'profile', - riskLevel: 'medium' - }, - - // Notifications - 'notifications:read': { - name: 'Read Notifications', - description: 'Read your notifications', - icon: '🔔', - category: 'notifications', - riskLevel: 'low' - }, - 'notifications:write': { - name: 'Manage Notifications', - description: 'Manage your notification settings', - icon: '⚙️', - category: 'notifications', - riskLevel: 'low' } } @@ -89,11 +50,6 @@ export const SCOPE_CATEGORIES = { description: 'Basic read access to public information', color: 'primary' }, - content: { - name: 'Content Creation', - description: 'Create and manage posts and comments', - color: 'info' - }, wallet: { name: 'Wallet Access', description: 'Access to wallet functions', @@ -103,11 +59,6 @@ export const SCOPE_CATEGORIES = { name: 'Profile Management', description: 'Access to profile information', color: 'secondary' - }, - notifications: { - name: 'Notifications', - description: 'Access to notification system', - color: 'dark' } } @@ -194,16 +145,6 @@ export function minimizeScopes (scopes) { // Remove redundant scopes based on hierarchy let minimized = [...scopes] - // If write:posts is present, remove read (since write implies read) - if (minimized.includes('write:posts') || minimized.includes('write:comments')) { - minimized = minimized.filter(s => s !== 'read') - } - - // If profile:write is present, remove profile:read - if (minimized.includes('profile:write')) { - minimized = minimized.filter(s => s !== 'profile:read') - } - // If wallet:send is present, remove wallet:read if (minimized.includes('wallet:send')) { minimized = minimized.filter(s => s !== 'wallet:read') @@ -216,16 +157,6 @@ export function expandScopes (scopes) { // Add implied scopes const expanded = new Set(scopes) - // Write permissions include read - if (scopes.some(s => s.startsWith('write:'))) { - expanded.add('read') - } - - // profile:write includes profile:read - if (scopes.includes('profile:write')) { - expanded.add('profile:read') - } - // wallet:send includes wallet:read if (scopes.includes('wallet:send')) { expanded.add('wallet:read') @@ -243,12 +174,6 @@ export function formatScopeList (scopes) { export function getScopeHierarchy () { return { - read: { - implied_by: ['write:posts', 'write:comments'] - }, - 'profile:read': { - implied_by: ['profile:write'] - }, 'wallet:read': { implied_by: ['wallet:send'] } diff --git a/pages/api/oauth/applications.js b/pages/api/oauth/applications.js index 2f0b2e611..b2aebc014 100644 --- a/pages/api/oauth/applications.js +++ b/pages/api/oauth/applications.js @@ -68,9 +68,8 @@ async function createApplication (req, res, session) { // Validate scopes const validScopes = [ - 'read', 'write:posts', 'write:comments', 'wallet:read', - 'wallet:send', 'wallet:receive', 'profile:read', 'profile:write', - 'notifications:read', 'notifications:write' + 'read', 'wallet:read', + 'wallet:send', 'wallet:receive', 'profile:read' ] if (!scopes || !Array.isArray(scopes) || scopes.length === 0) { diff --git a/pages/api/oauth/applications/[id].js b/pages/api/oauth/applications/[id].js index 10f917a41..ecea80ea8 100644 --- a/pages/api/oauth/applications/[id].js +++ b/pages/api/oauth/applications/[id].js @@ -171,9 +171,8 @@ async function updateApplication (req, res, session, applicationId) { if (scopes !== undefined) { const validScopes = [ - 'read', 'write:posts', 'write:comments', 'wallet:read', - 'wallet:send', 'wallet:receive', 'profile:read', 'profile:write', - 'notifications:read', 'notifications:write' + 'read', 'wallet:read', + 'wallet:send', 'wallet:receive', 'profile:read' ] if (!Array.isArray(scopes) || scopes.length === 0) { diff --git a/pages/api/oauth/authorize.js b/pages/api/oauth/authorize.js index 555731342..b32819c42 100644 --- a/pages/api/oauth/authorize.js +++ b/pages/api/oauth/authorize.js @@ -11,7 +11,7 @@ export default async function handler (req, res) { return await handleAuthorizationConsent(req, res) } else { res.setHeader('Allow', ['GET', 'POST']) - return res.status(405).json({ error: 'Method not allowed' }) + res.status(405).json({ error: 'Method not allowed' }) } } diff --git a/pages/api/oauth/userinfo.js b/pages/api/oauth/userinfo.js new file mode 100644 index 000000000..03c695b14 --- /dev/null +++ b/pages/api/oauth/userinfo.js @@ -0,0 +1,43 @@ +import { authenticateOAuth } from '../../../lib/oauth-auth' +import models from '../../../api/models' + +export default async function handler (req, res) { + if (req.method !== 'GET') { + res.setHeader('Allow', ['GET']) + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const auth = await authenticateOAuth(req, ['read', 'profile:read']) + if (!auth.success) { + return res.status(401).json({ error: auth.error }) + } + + const { user } = auth + + // Fetch user details from the database + const userDetails = await models.user.findUnique({ + where: { id: user.id }, + select: { + id: true, + name: true, + createdAt: true, + photoId: true + } + }) + + if (!userDetails) { + return res.status(404).json({ error: 'User not found' }) + } + + return res.status(200).json({ + id: userDetails.id, + name: userDetails.name, + created_at: userDetails.createdAt.toISOString(), + photo_id: userDetails.photoId + }) + } catch (error) { + console.error('Error in OAuth userinfo endpoint:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} diff --git a/pages/api/oauth/wallet/invoices.js b/pages/api/oauth/wallet/invoices.js index 235617ba0..c3ed096a9 100644 --- a/pages/api/oauth/wallet/invoices.js +++ b/pages/api/oauth/wallet/invoices.js @@ -146,6 +146,10 @@ async function createInvoice (req, res) { if (requestedAmountMsats <= autoApproveThreshold) { // Auto-approve and create actual invoice + if (!lnd) { + console.error('LND not initialized') + return res.status(500).json({ error: 'Lightning Network Daemon not available' }) + } const invoiceArgs = await createSNInvoice(InvoiceActionType.RECEIVE, { }, { me: user, lnd, diff --git a/pages/api/oauth/wallet/send.js b/pages/api/oauth/wallet/send.js index 281a49f40..49234ece2 100644 --- a/pages/api/oauth/wallet/send.js +++ b/pages/api/oauth/wallet/send.js @@ -17,7 +17,7 @@ export default async function handler (req, res) { } const { user, accessToken } = auth - const { bolt11, max_fee_msats: maxFeeMsats } = req.body + const { bolt11, max_fee_sats: maxFeeSats } = req.body if (!bolt11) { return res.status(400).json({ error: 'bolt11 payment request is required' }) @@ -26,9 +26,10 @@ export default async function handler (req, res) { // Parse and validate the payment request let parsedPaymentRequest try { - parsedPaymentRequest = parsePaymentRequest(bolt11) + parsedPaymentRequest = parsePaymentRequest({ request: bolt11 }) } catch (error) { - return res.status(400).json({ error: 'Invalid payment request' }) + console.error('Error parsing payment request:', error) + return res.status(400).json({ error: `Invalid payment request: ${error.message || 'unknown error'}` }) } const amountMsats = BigInt(parsedPaymentRequest.mtokens || 0) @@ -67,7 +68,7 @@ export default async function handler (req, res) { description: parsedPaymentRequest.description || 'OAuth app payment', metadata: { destination: parsedPaymentRequest.destination, - max_fee_msats: maxFeeMsats?.toString() || null, + max_fee_sats: maxFeeSats?.toString() || null, expires_at: parsedPaymentRequest.expires_at }, status: 'pending_approval', @@ -80,7 +81,16 @@ export default async function handler (req, res) { if (amountMsats <= autoApproveThreshold) { // In a real implementation, this would actually send the payment - const withdrawal = await createWithdrawal(null, { invoice: bolt11, maxFee: maxFeeMsats ? Number(maxFeeMsats) : undefined }, { me: user, models, lnd }) + let withdrawal + try { + withdrawal = await createWithdrawal(null, { invoice: bolt11, maxFee: maxFeeSats ? Number(maxFeeSats) : undefined }, { me: user, models, lnd }) + } catch (error) { + if (error.message.includes('insufficient funds')) { + return res.status(400).json({ error: 'Insufficient balance to cover payment and max fee' }) + } + // Re-throw other errors + throw error + } await models.oAuthWalletTransaction.update({ where: { id: paymentRequest.id }, diff --git a/pages/oauth/consent.js b/pages/oauth/consent.js index 02d586b21..62129033f 100644 --- a/pages/oauth/consent.js +++ b/pages/oauth/consent.js @@ -6,28 +6,18 @@ import Layout from '../../components/layout' const SCOPE_DESCRIPTIONS = { read: 'Read your public profile, posts, and comments', - 'write:posts': 'Create and edit posts on your behalf', - 'write:comments': 'Create and edit comments on your behalf', 'wallet:read': 'View your wallet balance and transaction history', 'wallet:send': 'Send payments from your wallet', 'wallet:receive': 'Create invoices and receive payments to your wallet', - 'profile:read': 'Access your profile information and settings', - 'profile:write': 'Update your profile information and settings', - 'notifications:read': 'Read your notifications', - 'notifications:write': 'Manage your notification settings' + 'profile:read': 'Access your profile information and settings' } const SCOPE_ICONS = { read: '👁️', - 'write:posts': '✍️', - 'write:comments': '💬', 'wallet:read': '👀', 'wallet:send': '⚡', 'wallet:receive': '📥', - 'profile:read': '👤', - 'profile:write': '✏️', - 'notifications:read': '🔔', - 'notifications:write': '⚙️' + 'profile:read': '👤' } export default function OAuthConsent ({ application = {}, scopes = [], params }) { @@ -52,16 +42,15 @@ export default function OAuthConsent ({ application = {}, scopes = [], params }) }) }) + if (response.redirected) { + window.location.href = response.url + return + } + if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || 'Authorization failed') } - - // The API endpoint will redirect, but handle it manually in case - const data = await response.json() - if (data.redirect_url) { - window.location.href = data.redirect_url - } } catch (err) { setError(err.message) setLoading(false) diff --git a/pages/settings/oauth-applications.js b/pages/settings/oauth-applications.js index d65e9ca65..05e35a97c 100644 --- a/pages/settings/oauth-applications.js +++ b/pages/settings/oauth-applications.js @@ -29,15 +29,10 @@ export default function OAuthApplications () { const availableScopes = [ 'read', - 'write:posts', - 'write:comments', 'wallet:read', 'wallet:send', 'wallet:receive', - 'profile:read', - 'profile:write', - 'notifications:read', - 'notifications:write' + 'profile:read' ] useEffect(() => { diff --git a/prisma/migrations/20250624_oauth_support/migration.sql b/prisma/migrations/20250624_oauth_support/migration.sql index 189f9e1b8..eec5b8ea9 100644 --- a/prisma/migrations/20250624_oauth_support/migration.sql +++ b/prisma/migrations/20250624_oauth_support/migration.sql @@ -4,15 +4,10 @@ CREATE TYPE "OAuthGrantType" AS ENUM ('authorization_code', 'refresh_token', 'cl -- CreateEnum CREATE TYPE "OAuthScope" AS ENUM ( 'read', - 'write:posts', - 'write:comments', 'wallet:read', 'wallet:send', 'wallet:receive', - 'profile:read', - 'profile:write', - 'notifications:read', - 'notifications:write' + 'profile:read' ); -- CreateEnum diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4260bb9ed..2da6d0df1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1315,15 +1315,10 @@ enum OAuthGrantType { enum OAuthScope { read - write_posts @map("write:posts") - write_comments @map("write:comments") wallet_read @map("wallet:read") wallet_send @map("wallet:send") wallet_receive @map("wallet:receive") profile_read @map("profile:read") - profile_write @map("profile:write") - notifications_read @map("notifications:read") - notifications_write @map("notifications:write") } enum OAuthTokenType {