@remcostoeten/fync
Version:
Unified TypeScript library for 9 popular APIs with consistent functional architecture
548 lines (543 loc) • 20.8 kB
JavaScript
"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"
}
}
*/