UNPKG

@remcostoeten/fync

Version:

Unified TypeScript library for 9 popular APIs with consistent functional architecture

548 lines (543 loc) 20.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AuthService = void 0; exports.GET = GET; exports.GitHubCallbackHandler = GitHubCallbackHandler; exports.GitHubIntegrationExample = GitHubIntegrationExample; exports.GoogleAuthHandler = GoogleAuthHandler; exports.GoogleCalendarExample = GoogleCalendarExample; exports.GoogleCallbackHandler = GoogleCallbackHandler; exports.accounts = void 0; exports.authMiddleware = authMiddleware; exports.users = exports.sessions = exports.googleOAuth = exports.githubOAuth = exports.db = void 0; var _postgresJs = require("drizzle-orm/postgres-js"); var _pgCore = require("drizzle-orm/pg-core"); var _drizzleOrm = require("drizzle-orm"); var _postgres = _interopRequireDefault(require("postgres")); var _fync = require("@remcostoeten/fync"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } /** * Complete OAuth2 + Drizzle ORM Integration Example for Next.js * * This example demonstrates how to integrate fync OAuth2 authentication * with Drizzle ORM in a Next.js application following modular architecture. * * Features: * - GitHub & Google OAuth2 authentication * - User session management with Drizzle ORM * - Refresh token handling * - Modular architecture following your design principles * - Type-safe database operations * - Middleware for protected routes */ // ============================================================================== // Database Schema (src/server/db/schemas/index.ts) // ============================================================================== const users = exports.users = (0, _pgCore.pgTable)('users', { id: (0, _pgCore.text)('id').primaryKey(), email: (0, _pgCore.text)('email').notNull().unique(), name: (0, _pgCore.text)('name'), avatarUrl: (0, _pgCore.text)('avatar_url'), createdAt: (0, _pgCore.timestamp)('created_at').defaultNow().notNull(), updatedAt: (0, _pgCore.timestamp)('updated_at').defaultNow().notNull() }); const accounts = exports.accounts = (0, _pgCore.pgTable)('accounts', { id: (0, _pgCore.text)('id').primaryKey(), userId: (0, _pgCore.text)('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), provider: (0, _pgCore.text)('provider').notNull(), // 'github' | 'google' providerAccountId: (0, _pgCore.text)('provider_account_id').notNull(), accessToken: (0, _pgCore.text)('access_token'), refreshToken: (0, _pgCore.text)('refresh_token'), expiresAt: (0, _pgCore.timestamp)('expires_at'), tokenType: (0, _pgCore.text)('token_type'), scope: (0, _pgCore.text)('scope'), createdAt: (0, _pgCore.timestamp)('created_at').defaultNow().notNull(), updatedAt: (0, _pgCore.timestamp)('updated_at').defaultNow().notNull() }); const sessions = exports.sessions = (0, _pgCore.pgTable)('sessions', { id: (0, _pgCore.text)('id').primaryKey(), userId: (0, _pgCore.text)('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), expiresAt: (0, _pgCore.timestamp)('expires_at').notNull(), createdAt: (0, _pgCore.timestamp)('created_at').defaultNow().notNull() }); // ============================================================================== // Database Connection (src/server/db/index.ts) // ============================================================================== const connectionString = process.env.DATABASE_URL; const client = (0, _postgres.default)(connectionString); const db = exports.db = (0, _postgresJs.drizzle)(client); // ============================================================================== // OAuth Configuration (src/modules/authentication/config/oauth.ts) // ============================================================================== const githubOAuth = exports.githubOAuth = (0, _fync.GitHubOAuth)({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, redirectUri: process.env.GITHUB_REDIRECT_URI || 'http://localhost:3000/auth/github/callback' }); const googleOAuth = exports.googleOAuth = (0, _fync.GoogleOAuth)({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, redirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/auth/google/callback' }); // ============================================================================== // Authentication Service (src/modules/authentication/services/auth-service.ts) // ============================================================================== class AuthService { /** * Create or update user from GitHub OAuth */ static async handleGitHubCallback(code, state) { try { // Exchange code for tokens const tokens = await githubOAuth.exchangeCodeForToken(code, state); // Get user info const githubUser = await githubOAuth.withToken(tokens.access_token).getUser(); // Get primary email const primaryEmail = await githubOAuth.withToken(tokens.access_token).getPrimaryEmail(); if (!primaryEmail) { throw new Error('GitHub account must have a public email address'); } // Create/update user and account const user = await this.upsertUser({ email: primaryEmail, name: githubUser.name || githubUser.login, avatarUrl: githubUser.avatar_url }); await this.upsertAccount({ userId: user.id, provider: 'github', providerAccountId: githubUser.id.toString(), accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null, tokenType: tokens.token_type, scope: tokens.scope }); // Create session const session = await this.createSession(user.id); return { user, session, tokens }; } catch (error) { throw new Error(`GitHub OAuth failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Create or update user from Google OAuth */ static async handleGoogleCallback(code, codeVerifier) { try { // Exchange code for tokens const tokens = await googleOAuth.exchangeCodeForToken(code, codeVerifier); // Get user info const googleUser = await googleOAuth.withToken(tokens.access_token).getUser(); // Create/update user and account const user = await this.upsertUser({ email: googleUser.email, name: googleUser.name, avatarUrl: googleUser.picture }); await this.upsertAccount({ userId: user.id, provider: 'google', providerAccountId: googleUser.id, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000), tokenType: tokens.token_type, scope: tokens.scope }); // Create session const session = await this.createSession(user.id); return { user, session, tokens }; } catch (error) { throw new Error(`Google OAuth failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Refresh access token for a user account */ static async refreshAccessToken(userId, provider) { const account = await db.select().from(accounts).where((0, _drizzleOrm.and)((0, _drizzleOrm.eq)(accounts.userId, userId), (0, _drizzleOrm.eq)(accounts.provider, provider))).limit(1); if (!account[0] || !account[0].refreshToken) { throw new Error('No refresh token available'); } try { let newTokens; if (provider === 'github') { newTokens = await githubOAuth.refreshToken(account[0].refreshToken); } else { newTokens = await googleOAuth.refreshToken(account[0].refreshToken); } // Update account with new tokens await db.update(accounts).set({ accessToken: newTokens.access_token, refreshToken: newTokens.refresh_token || account[0].refreshToken, expiresAt: new Date(Date.now() + newTokens.expires_in * 1000), updatedAt: new Date() }).where((0, _drizzleOrm.eq)(accounts.id, account[0].id)); return newTokens; } catch (error) { throw new Error(`Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Validate and refresh token if needed */ static async getValidAccessToken(userId, provider) { const account = await db.select().from(accounts).where((0, _drizzleOrm.and)((0, _drizzleOrm.eq)(accounts.userId, userId), (0, _drizzleOrm.eq)(accounts.provider, provider))).limit(1); if (!account[0]) { throw new Error('Account not found'); } // Check if token is expired or will expire soon (5 minutes buffer) const now = new Date(); const expiresAt = account[0].expiresAt; const bufferTime = 5 * 60 * 1000; // 5 minutes if (expiresAt && expiresAt.getTime() - now.getTime() < bufferTime) { // Token is expired or will expire soon, refresh it const newTokens = await this.refreshAccessToken(userId, provider); return newTokens.access_token; } return account[0].accessToken; } /** * Create or update user */ static async upsertUser(userData) { const existingUser = await db.select().from(users).where((0, _drizzleOrm.eq)(users.email, userData.email)).limit(1); if (existingUser[0]) { // Update existing user const [updatedUser] = await db.update(users).set({ ...userData, updatedAt: new Date() }).where((0, _drizzleOrm.eq)(users.id, existingUser[0].id)).returning(); return updatedUser; } else { // Create new user const [newUser] = await db.insert(users).values({ id: crypto.randomUUID(), ...userData }).returning(); return newUser; } } /** * Create or update account */ static async upsertAccount(accountData) { const existingAccount = await db.select().from(accounts).where((0, _drizzleOrm.and)((0, _drizzleOrm.eq)(accounts.userId, accountData.userId), (0, _drizzleOrm.eq)(accounts.provider, accountData.provider))).limit(1); if (existingAccount[0]) { // Update existing account const [updatedAccount] = await db.update(accounts).set({ ...accountData, updatedAt: new Date() }).where((0, _drizzleOrm.eq)(accounts.id, existingAccount[0].id)).returning(); return updatedAccount; } else { // Create new account const [newAccount] = await db.insert(accounts).values({ id: crypto.randomUUID(), ...accountData }).returning(); return newAccount; } } /** * Create a new session */ static async createSession(userId) { // Remove old sessions for this user await db.delete(sessions).where((0, _drizzleOrm.eq)(sessions.userId, userId)); // Create new session (expires in 7 days) const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const [session] = await db.insert(sessions).values({ id: crypto.randomUUID(), userId, expiresAt }).returning(); return session; } /** * Get session with user */ static async getSessionWithUser(sessionId) { const result = await db.select({ session: sessions, user: users }).from(sessions).innerJoin(users, (0, _drizzleOrm.eq)(sessions.userId, users.id)).where((0, _drizzleOrm.eq)(sessions.id, sessionId)).limit(1); if (!result[0]) { return null; } // Check if session is expired if (result[0].session.expiresAt < new Date()) { await db.delete(sessions).where((0, _drizzleOrm.eq)(sessions.id, sessionId)); return null; } return result[0]; } /** * Delete session (logout) */ static async deleteSession(sessionId) { await db.delete(sessions).where((0, _drizzleOrm.eq)(sessions.id, sessionId)); } /** * Revoke OAuth tokens and delete account */ static async revokeAccount(userId, provider) { const account = await db.select().from(accounts).where((0, _drizzleOrm.and)((0, _drizzleOrm.eq)(accounts.userId, userId), (0, _drizzleOrm.eq)(accounts.provider, provider))).limit(1); if (account[0] && account[0].accessToken) { try { if (provider === 'github') { await githubOAuth.revokeToken(account[0].accessToken); } else { await googleOAuth.revokeToken(account[0].accessToken); } } catch (error) { console.error('Failed to revoke token:', error); } } await db.delete(accounts).where((0, _drizzleOrm.eq)(accounts.id, account[0].id)); } } // ============================================================================== // Next.js API Routes // ============================================================================== // app/auth/github/route.ts exports.AuthService = AuthService; async function GET() { const { url, codeVerifier } = githubOAuth.getAuthorizationUrl({ scope: ['user:email', 'repo'], state: crypto.randomUUID() }); // In a real app, you'd store the state and codeVerifier in a secure way // For example, in a signed cookie or session store return Response.redirect(url); } // app/auth/github/callback/route.ts async function GitHubCallbackHandler(request) { const { searchParams } = new URL(request.url); const code = searchParams.get('code'); const state = searchParams.get('state'); const error = searchParams.get('error'); if (error) { return Response.redirect('/auth/error?error=' + encodeURIComponent(error)); } if (!code) { return Response.redirect('/auth/error?error=missing_code'); } try { const { user, session } = await AuthService.handleGitHubCallback(code, state || undefined); // Set session cookie const response = Response.redirect('/dashboard'); response.cookies.set('session', session.id, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 // 7 days }); return response; } catch (error) { console.error('GitHub OAuth error:', error); return Response.redirect('/auth/error?error=oauth_failed'); } } // app/auth/google/route.ts async function GoogleAuthHandler() { const { url, codeVerifier } = googleOAuth.getAuthorizationUrl({ scope: ['openid', 'email', 'profile', 'https://www.googleapis.com/auth/calendar'], state: crypto.randomUUID(), accessType: 'offline', // Required for refresh tokens pkce: true // Use PKCE for security }); // Store codeVerifier securely (e.g., in encrypted cookie) const response = Response.redirect(url); response.cookies.set('oauth_code_verifier', codeVerifier, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 10 * 60 // 10 minutes }); return response; } // app/auth/google/callback/route.ts async function GoogleCallbackHandler(request) { const { searchParams } = new URL(request.url); const code = searchParams.get('code'); const error = searchParams.get('error'); const codeVerifier = request.cookies.get('oauth_code_verifier')?.value; if (error) { return Response.redirect('/auth/error?error=' + encodeURIComponent(error)); } if (!code) { return Response.redirect('/auth/error?error=missing_code'); } try { const { user, session } = await AuthService.handleGoogleCallback(code, codeVerifier); // Set session cookie and clear code verifier const response = Response.redirect('/dashboard'); response.cookies.set('session', session.id, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 // 7 days }); response.cookies.delete('oauth_code_verifier'); return response; } catch (error) { console.error('Google OAuth error:', error); return Response.redirect('/auth/error?error=oauth_failed'); } } // ============================================================================== // Middleware for Authentication (middleware.ts) // ============================================================================== async function authMiddleware(request) { const sessionId = request.cookies.get('session')?.value; if (!sessionId) { return Response.redirect(new URL('/auth/login', request.url)); } const sessionData = await AuthService.getSessionWithUser(sessionId); if (!sessionData) { const response = Response.redirect(new URL('/auth/login', request.url)); response.cookies.delete('session'); return response; } // Add user info to headers for the app to use const requestHeaders = new Headers(request.headers); requestHeaders.set('x-user-id', sessionData.user.id); requestHeaders.set('x-user-email', sessionData.user.email); return Response.next({ request: { headers: requestHeaders } }); } // ============================================================================== // Usage Examples in Components // ============================================================================== // Example: Protected page that uses GitHub API async function GitHubIntegrationExample({ userId }) { try { // Get valid access token (automatically refreshes if needed) const accessToken = await AuthService.getValidAccessToken(userId, 'github'); // Use the token with fync GitHub API const github = GitHub({ token: accessToken }); const user = await github.me.getAuthenticatedUser(); const repos = await github.me.getMyRepos(); return { user, repos }; } catch (error) { console.error('GitHub API error:', error); throw error; } } // Example: Google Calendar integration async function GoogleCalendarExample({ userId }) { try { // Get valid access token (automatically refreshes if needed) const accessToken = await AuthService.getValidAccessToken(userId, 'google'); // Use the token with fync Google Calendar API const { GoogleCalendar } = await Promise.resolve().then(() => _interopRequireWildcard(require('@remcostoeten/fync/google-calendar'))); const calendar = GoogleCalendar({ token: accessToken }); const events = await calendar.getUpcomingEvents('primary', 10); const calendars = await calendar.getCalendars(); return { events, calendars }; } catch (error) { console.error('Google Calendar API error:', error); throw error; } } // ============================================================================== // Environment Variables (.env.local) // ============================================================================== /* # Database DATABASE_URL="postgresql://username:password@localhost:5432/your_db" # GitHub OAuth GITHUB_CLIENT_ID="your_github_client_id" GITHUB_CLIENT_SECRET="your_github_client_secret" GITHUB_REDIRECT_URI="http://localhost:3000/auth/github/callback" # Google OAuth GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="your_google_client_secret" GOOGLE_REDIRECT_URI="http://localhost:3000/auth/google/callback" # Session SESSION_SECRET="your_session_secret_key" */ // ============================================================================== // Drizzle Configuration (drizzle.config.ts) // ============================================================================== /* import type { Config } from 'drizzle-kit'; export default { schema: './src/server/db/schemas/index.ts', out: './src/server/db/migrations', driver: 'pg', dbCredentials: { connectionString: process.env.DATABASE_URL!, }, } satisfies Config; */ // ============================================================================== // Package.json dependencies // ============================================================================== /* { "dependencies": { "@remcostoeten/fync": "^5.0.0", "drizzle-orm": "^0.29.0", "postgres": "^3.4.0", "next": "^14.0.0" }, "devDependencies": { "drizzle-kit": "^0.20.0", "@types/node": "^20.0.0" } } */