···77db.exec(`
88 CREATE TABLE IF NOT EXISTS users (
99 id INTEGER PRIMARY KEY AUTOINCREMENT,
1010- canvas_user_id TEXT UNIQUE NOT NULL,
1111- canvas_domain TEXT NOT NULL,
1010+ canvas_user_id TEXT,
1111+ canvas_domain TEXT,
1212 email TEXT,
1313- canvas_access_token TEXT NOT NULL,
1313+ canvas_access_token TEXT,
1414 canvas_refresh_token TEXT,
1515- mcp_api_key TEXT UNIQUE NOT NULL,
1616- created_at INTEGER NOT NULL,
1515+ mcp_api_key TEXT UNIQUE,
1616+ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
1717 last_used_at INTEGER,
1818 token_expires_at INTEGER
1919 );
···3636 expires_at INTEGER NOT NULL
3737 );
38383939+ CREATE TABLE IF NOT EXISTS magic_links (
4040+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4141+ email TEXT NOT NULL,
4242+ token TEXT UNIQUE NOT NULL,
4343+ expires_at INTEGER NOT NULL,
4444+ used INTEGER DEFAULT 0,
4545+ created_at INTEGER DEFAULT (unixepoch() * 1000)
4646+ );
4747+4848+ CREATE TABLE IF NOT EXISTS auth_codes (
4949+ code TEXT PRIMARY KEY,
5050+ user_id INTEGER NOT NULL,
5151+ client_id TEXT NOT NULL,
5252+ redirect_uri TEXT NOT NULL,
5353+ code_challenge TEXT NOT NULL,
5454+ code_challenge_method TEXT NOT NULL,
5555+ scope TEXT NOT NULL,
5656+ expires_at INTEGER NOT NULL,
5757+ created_at INTEGER DEFAULT (unixepoch() * 1000),
5858+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
5959+ );
6060+6161+ CREATE TABLE IF NOT EXISTS oauth_tokens (
6262+ token TEXT PRIMARY KEY,
6363+ user_id INTEGER NOT NULL,
6464+ scope TEXT NOT NULL,
6565+ expires_at INTEGER NOT NULL,
6666+ created_at INTEGER DEFAULT (unixepoch() * 1000),
6767+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
6868+ );
6969+3970 CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(mcp_api_key);
4071 CREATE INDEX IF NOT EXISTS idx_users_canvas_id ON users(canvas_user_id);
7272+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
4173 CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id);
4274 CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
7575+ CREATE INDEX IF NOT EXISTS idx_magic_links_token ON magic_links(token);
7676+ CREATE INDEX IF NOT EXISTS idx_magic_links_email ON magic_links(email);
7777+ CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes(code);
7878+ CREATE INDEX IF NOT EXISTS idx_oauth_tokens_token ON oauth_tokens(token);
4379`);
44804581// Encryption utilities
···124160 ? encrypt(data.canvas_refresh_token)
125161 : null;
126162127127- // Check if user exists
128128- const existing = db
163163+ // Check if user exists by canvas_user_id or email
164164+ let existing = db
129165 .query("SELECT * FROM users WHERE canvas_user_id = ?")
130166 .get(data.canvas_user_id) as User | null;
131167168168+ // If not found by canvas_user_id, check by email (for magic link users)
169169+ if (!existing && data.email) {
170170+ existing = db
171171+ .query("SELECT * FROM users WHERE email = ? AND canvas_user_id IS NULL")
172172+ .get(data.email) as User | null;
173173+ }
174174+132175 if (existing) {
133133- // Update existing user
176176+ // Check if user needs an API key (magic link users)
177177+ let apiKey: string | null = null;
178178+ let hashedApiKey = existing.mcp_api_key;
179179+180180+ if (!hashedApiKey) {
181181+ // Generate API key for magic link users connecting Canvas for first time
182182+ apiKey = generateApiKey();
183183+ hashedApiKey = await hashApiKey(apiKey);
184184+ }
185185+186186+ // Update existing user (might not have canvas_user_id if from magic link)
134187 db.run(
135188 `UPDATE users SET
189189+ canvas_user_id = ?,
190190+ canvas_domain = ?,
136191 canvas_access_token = ?,
137192 canvas_refresh_token = ?,
138193 token_expires_at = ?,
139139- last_used_at = ?
140140- WHERE canvas_user_id = ?`,
194194+ last_used_at = ?,
195195+ mcp_api_key = ?
196196+ WHERE id = ?`,
141197 [
198198+ data.canvas_user_id,
199199+ data.canvas_domain,
142200 encryptedToken,
143201 encryptedRefreshToken,
144202 data.token_expires_at,
145203 Date.now(),
146146- data.canvas_user_id,
204204+ hashedApiKey,
205205+ existing.id,
147206 ]
148207 );
149208150209 const user = db
151151- .query("SELECT * FROM users WHERE canvas_user_id = ?")
152152- .get(data.canvas_user_id) as User;
210210+ .query("SELECT * FROM users WHERE id = ?")
211211+ .get(existing.id) as User;
153212154154- // Return null for existing users - they need to regenerate if they lost it
155155- // We can't return the plaintext key since it's hashed in the database
156156- return { user, apiKey: null, isNewUser: false };
213213+ // Return API key only if we just generated it (for magic link users)
214214+ const isNewUser = apiKey !== null;
215215+ return { user, apiKey, isNewUser };
157216 } else {
158217 // Create new user with API key
159218 const apiKey = generateApiKey();
···327386 return db
328387 .query("SELECT * FROM sessions WHERE api_key = ? AND expires_at > ?")
329388 .get(token, Date.now()) as any;
389389+ },
390390+391391+ // Magic link authentication
392392+ createMagicLink(email: string, token: string, expiresAt: number) {
393393+ return db.run(
394394+ "INSERT INTO magic_links (email, token, expires_at) VALUES (?, ?, ?)",
395395+ [email, token, expiresAt]
396396+ );
397397+ },
398398+399399+ getMagicLink(token: string) {
400400+ // Clean up expired magic links
401401+ db.run("DELETE FROM magic_links WHERE expires_at < ?", [Date.now()]);
402402+403403+ return db
404404+ .query(
405405+ "SELECT * FROM magic_links WHERE token = ? AND expires_at > ? AND used = 0"
406406+ )
407407+ .get(token, Date.now()) as any;
408408+ },
409409+410410+ markMagicLinkUsed(token: string) {
411411+ return db.run("UPDATE magic_links SET used = 1 WHERE token = ?", [token]);
412412+ },
413413+414414+ getUserByEmail(email: string) {
415415+ return db.query("SELECT * FROM users WHERE email = ?").get(email) as any;
416416+ },
417417+418418+ // Update user with Canvas credentials (for magic link users)
419419+ async updateUserCanvas(
420420+ userId: number,
421421+ canvasUserId: string,
422422+ canvasDomain: string,
423423+ canvasToken: string
424424+ ): Promise<{ apiKey: string | null }> {
425425+ const existing = db.query("SELECT * FROM users WHERE id = ?").get(userId) as User | null;
426426+427427+ if (!existing) {
428428+ throw new Error("User not found");
429429+ }
430430+431431+ const encryptedToken = encrypt(canvasToken);
432432+433433+ // Generate API key if user doesn't have one
434434+ let apiKey: string | null = null;
435435+ let hashedApiKey = existing.mcp_api_key;
436436+437437+ if (!hashedApiKey) {
438438+ apiKey = generateApiKey();
439439+ hashedApiKey = await hashApiKey(apiKey);
440440+ }
441441+442442+ // Update user with Canvas credentials
443443+ db.run(
444444+ `UPDATE users SET
445445+ canvas_user_id = ?,
446446+ canvas_domain = ?,
447447+ canvas_access_token = ?,
448448+ mcp_api_key = ?,
449449+ last_used_at = ?
450450+ WHERE id = ?`,
451451+ [
452452+ canvasUserId,
453453+ canvasDomain,
454454+ encryptedToken,
455455+ hashedApiKey,
456456+ Date.now(),
457457+ userId,
458458+ ]
459459+ );
460460+461461+ return { apiKey };
462462+ },
463463+464464+ // Rate limiting for magic links
465465+ canSendMagicLink(email: string, cooldownMs: number = 60000): boolean {
466466+ // Clean up old magic links first
467467+ db.run("DELETE FROM magic_links WHERE expires_at < ?", [Date.now()]);
468468+469469+ // Check if a magic link was sent recently (within cooldown period)
470470+ const recent = db
471471+ .query(
472472+ "SELECT * FROM magic_links WHERE email = ? AND created_at > ? ORDER BY created_at DESC LIMIT 1"
473473+ )
474474+ .get(email, Date.now() - cooldownMs) as any;
475475+476476+ return !recent;
477477+ },
478478+479479+ getLastMagicLinkTime(email: string): number | null {
480480+ const recent = db
481481+ .query(
482482+ "SELECT created_at FROM magic_links WHERE email = ? ORDER BY created_at DESC LIMIT 1"
483483+ )
484484+ .get(email) as any;
485485+486486+ return recent ? recent.created_at : null;
487487+ },
488488+489489+ // OAuth tokens
490490+ createOAuthToken(userId: number, scope: string, expiresIn: number = 86400000): string {
491491+ const token = generateApiKey(); // Reuse the API key generator
492492+ const expiresAt = Date.now() + expiresIn;
493493+494494+ db.run(
495495+ "INSERT INTO oauth_tokens (token, user_id, scope, expires_at) VALUES (?, ?, ?, ?)",
496496+ [token, userId, scope, expiresAt]
497497+ );
498498+499499+ return token;
500500+ },
501501+502502+ getUserByOAuthToken(token: string): User | null {
503503+ // Clean up expired tokens
504504+ db.run("DELETE FROM oauth_tokens WHERE expires_at < ?", [Date.now()]);
505505+506506+ const tokenData = db
507507+ .query("SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > ?")
508508+ .get(token, Date.now()) as any;
509509+510510+ if (!tokenData) {
511511+ return null;
512512+ }
513513+514514+ return db.query("SELECT * FROM users WHERE id = ?").get(tokenData.user_id) as User | null;
330515 },
331516};
332517
+170
src/lib/email.ts
···11+import nodemailer from "nodemailer";
22+import { readFileSync } from "fs";
33+import dkim from "nodemailer-dkim";
44+55+const SMTP_HOST = process.env.SMTP_HOST;
66+const SMTP_PORT = process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : undefined;
77+const SMTP_USER = process.env.SMTP_USER;
88+const SMTP_PASS = process.env.SMTP_PASS;
99+const SMTP_FROM = process.env.SMTP_FROM;
1010+const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
1111+1212+// DKIM Configuration (optional)
1313+const DKIM_SELECTOR = process.env.DKIM_SELECTOR;
1414+const DKIM_DOMAIN = process.env.DKIM_DOMAIN;
1515+const DKIM_PRIVATE_KEY_FILE = process.env.DKIM_PRIVATE_KEY_FILE;
1616+1717+class Mailer {
1818+ private transporter: any;
1919+ private enabled: boolean;
2020+2121+ constructor() {
2222+ // Check if SMTP is configured
2323+ if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) {
2424+ console.warn("SMTP not configured - email functionality disabled");
2525+ this.enabled = false;
2626+ return;
2727+ }
2828+2929+ this.enabled = true;
3030+3131+ // Create SMTP transporter
3232+ this.transporter = nodemailer.createTransport({
3333+ host: SMTP_HOST,
3434+ port: SMTP_PORT,
3535+ secure: false, // Use STARTTLS
3636+ auth: {
3737+ user: SMTP_USER,
3838+ pass: SMTP_PASS,
3939+ },
4040+ });
4141+4242+ // Add DKIM signing if configured
4343+ if (DKIM_SELECTOR && DKIM_DOMAIN && DKIM_PRIVATE_KEY_FILE) {
4444+ try {
4545+ const dkimPrivateKey = readFileSync(DKIM_PRIVATE_KEY_FILE, "utf-8");
4646+ this.transporter.use(
4747+ "stream",
4848+ dkim.signer({
4949+ domainName: DKIM_DOMAIN,
5050+ keySelector: DKIM_SELECTOR,
5151+ privateKey: dkimPrivateKey,
5252+ headerFieldNames: "from:to:subject:date:message-id",
5353+ })
5454+ );
5555+ console.log("DKIM signing enabled");
5656+ } catch (error) {
5757+ console.warn("DKIM private key not found, emails will not be signed");
5858+ }
5959+ }
6060+ }
6161+6262+ private async sendMail(
6363+ to: string,
6464+ subject: string,
6565+ html: string,
6666+ text: string
6767+ ): Promise<void> {
6868+ if (!this.enabled) {
6969+ throw new Error("Email is not configured");
7070+ }
7171+7272+ await this.transporter.sendMail({
7373+ from: SMTP_FROM,
7474+ to,
7575+ subject,
7676+ text,
7777+ html,
7878+ headers: {
7979+ "X-Mailer": "Canvas MCP",
8080+ },
8181+ });
8282+ }
8383+8484+ async sendMagicLink(email: string, token: string): Promise<void> {
8585+ const magicLink = `${BASE_URL}/auth/verify?token=${token}`;
8686+8787+ const html = `<!DOCTYPE html>
8888+<html>
8989+<head>
9090+ <meta charset="utf-8">
9191+ <meta name="viewport" content="width=device-width, initial-scale=1">
9292+ <style>
9393+ img { max-width: 100%; height: auto; }
9494+ </style>
9595+</head>
9696+<body style="font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 40px auto; padding: 20px;">
9797+ <div>
9898+ <h1 style="margin-bottom: 20px;">Sign in to Canvas MCP</h1>
9999+ <p>Click this link to sign in:</p>
100100+ <p><a href="${magicLink}" style="color: #0066cc;">${magicLink}</a></p>
101101+ <hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
102102+ <p style="font-size: 12px; color: #999;">This link expires in 15 minutes. If you didn't request this, ignore it.</p>
103103+ </div>
104104+</body>
105105+</html>`;
106106+107107+ const text = `Sign in to Canvas MCP
108108+109109+Click this link to sign in:
110110+${magicLink}
111111+112112+This link expires in 15 minutes.
113113+If you didn't request this, you can safely ignore it.`;
114114+115115+ await this.sendMail(email, "Sign in to Canvas MCP", html, text);
116116+ }
117117+118118+ async sendOAuthConfirmation(
119119+ email: string,
120120+ canvasDomain: string
121121+ ): Promise<void> {
122122+ const html = `<!DOCTYPE html>
123123+<html>
124124+<head>
125125+ <meta charset="utf-8">
126126+ <meta name="viewport" content="width=device-width, initial-scale=1">
127127+ <style>
128128+ img { max-width: 100%; height: auto; }
129129+ </style>
130130+</head>
131131+<body style="font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 40px auto; padding: 20px;">
132132+ <div>
133133+ <h1 style="margin-bottom: 20px;">Canvas Account Connected</h1>
134134+ <div style="background: #d4edda; color: #0a6640; padding: 16px; border-radius: 4px; margin: 20px 0;">
135135+ Your Canvas account has been successfully connected!
136136+ </div>
137137+ <p><strong>Canvas Domain:</strong> <code style="background: #f5f5f5; padding: 2px 6px; border-radius: 3px;">${canvasDomain}</code></p>
138138+ <p><a href="${BASE_URL}/dashboard" style="color: #0066cc;">View Dashboard →</a></p>
139139+ <hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
140140+ <h2 style="font-size: 18px;">Next Steps</h2>
141141+ <ol style="padding-left: 20px;">
142142+ <li>Configure Claude Desktop with the MCP server URL</li>
143143+ <li>Authorize Claude to access your Canvas data</li>
144144+ <li>Start asking questions about your courses!</li>
145145+ </ol>
146146+ </div>
147147+</body>
148148+</html>`;
149149+150150+ const text = `Canvas Account Connected!
151151+152152+Your Canvas account (${canvasDomain}) has been successfully connected.
153153+154154+Visit your dashboard: ${BASE_URL}/dashboard
155155+156156+Next Steps:
157157+1. Configure Claude Desktop with the MCP server URL
158158+2. Authorize Claude to access your Canvas data
159159+3. Start asking questions about your courses!`;
160160+161161+ await this.sendMail(
162162+ email,
163163+ "Canvas Account Connected - Canvas MCP",
164164+ html,
165165+ text
166166+ );
167167+ }
168168+}
169169+170170+export default new Mailer();
+11-4
src/lib/mcp-transport.ts
···1010): Promise<Response> {
1111 // Validate API token
1212 if (!apiToken) {
1313+ const baseUrl = process.env.BASE_URL || "http://localhost:3000";
1314 return new Response(
1415 JSON.stringify({
1516 jsonrpc: "2.0",
···2324 status: 401,
2425 headers: {
2526 "Content-Type": "application/json",
2626- "WWW-Authenticate": `Bearer realm="Canvas MCP Server"`,
2727+ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="canvas:read canvas:courses:read"`,
2728 },
2829 }
2930 );
3031 }
31323232- // Look up user by API key
3333- const user = await DB.getUserByApiKey(apiToken);
3333+ // Look up user by API key or OAuth token
3434+ let user = await DB.getUserByApiKey(apiToken);
3535+3636+ // If not found as API key, try as OAuth token
3737+ if (!user) {
3838+ user = DB.getUserByOAuthToken(apiToken);
3939+ }
4040+3441 if (!user) {
3542 return new Response(
3643 JSON.stringify({
3744 jsonrpc: "2.0",
3845 error: {
3946 code: -32001,
4040- message: "Invalid or expired API token",
4747+ message: "Invalid or expired token",
4148 },
4249 id: null,
4350 }),