diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts index 088e688..418ff04 100644 --- a/app/api/auth/callback/route.ts +++ b/app/api/auth/callback/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { getAuthEndpoints } from '@/lib/auth/atproto'; +import { consumeOAuthState } from '@/lib/auth/oauth-state'; import { mintSurrealJwt } from '@/lib/auth/jwt'; import { AtpAgent } from '@atproto/api'; @@ -12,27 +13,23 @@ export async function GET(request: NextRequest) { const code = searchParams.get('code'); const state = searchParams.get('state'); - // Get temporary values from cookies - const cookieStore = await cookies(); - const cookieState = cookieStore.get('atproto_oauth_state')?.value; - const code_verifier = cookieStore.get('atproto_pkce_verifier')?.value; - const pdsUrl = cookieStore.get('atproto_pds_url')?.value; - - // Clear temporary cookies - cookieStore.delete('atproto_oauth_state'); - cookieStore.delete('atproto_pkce_verifier'); - cookieStore.delete('atproto_pds_url'); - - // 1. Validate state (CSRF protection) - if (!state || state !== cookieState) { - return NextResponse.redirect(new URL('/login?error=Invalid state', request.url)); + // 1. Validate state parameter + if (!state || !code) { + return NextResponse.redirect(new URL('/login?error=Missing OAuth parameters', request.url)); } - // 2. Check for errors - if (!code || !pdsUrl || !code_verifier) { - return NextResponse.redirect(new URL('/login?error=Callback failed', request.url)); + // 2. Retrieve OAuth state from database (this also deletes it for one-time use) + console.log('[OAuth Callback] Looking up state:', state); + const oauthState = await consumeOAuthState(state); + console.log('[OAuth Callback] Retrieved oauthState:', oauthState); + + if (!oauthState) { + console.error('[OAuth Callback] Invalid or expired state:', state); + return NextResponse.redirect(new URL('/login?error=Invalid or expired state', request.url)); } + const { codeVerifier: code_verifier, pdsUrl } = oauthState; + try { // 3. Get the PDS's token endpoint const { tokenEndpoint } = await getAuthEndpoints(pdsUrl); @@ -75,30 +72,36 @@ export async function GET(request: NextRequest) { // 6. Mint OUR app's SurrealDB JWT const surrealJwt = mintSurrealJwt(session.did, session.handle); - // 7. Set the SurrealDB JWT in a secure cookie for our app - cookieStore.set('ponderants-auth', surrealJwt, { + // 7. Create redirect response + const response = NextResponse.redirect(new URL('/chat', request.url)); + + // 8. Set the SurrealDB JWT in a secure cookie on the response + response.cookies.set('ponderants-auth', surrealJwt, { httpOnly: true, secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, // 7 days path: '/', }); // Store the ATproto tokens for later use - cookieStore.set('atproto_access_token', access_token, { + response.cookies.set('atproto_access_token', access_token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', maxAge: 60 * 60, path: '/', }); - cookieStore.set('atproto_refresh_token', refresh_token, { + response.cookies.set('atproto_refresh_token', refresh_token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, path: '/', }); - // 8. Redirect to the main application - return NextResponse.redirect(new URL('/chat', request.url)); + // 9. Redirect to the main application + return response; } catch (error) { console.error('Auth callback error:', error); return NextResponse.redirect(new URL('/login?error=Internal auth error', request.url)); diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 9f902f5..0b89f16 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { getAuthEndpoints, resolveHandle } from '@/lib/auth/atproto'; +import { storeOAuthState } from '@/lib/auth/oauth-state'; import { randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } from 'openid-client'; const CLIENT_ID = process.env.BLUESKY_CLIENT_ID; @@ -30,11 +30,8 @@ export async function GET(request: NextRequest) { const code_verifier = randomPKCECodeVerifier(); const code_challenge = await calculatePKCECodeChallenge(code_verifier); - // 4. Store verifier and state in a temporary cookie - const cookieStore = await cookies(); - cookieStore.set('atproto_oauth_state', state, { httpOnly: true, maxAge: 600 }); - cookieStore.set('atproto_pkce_verifier', code_verifier, { httpOnly: true, maxAge: 600 }); - cookieStore.set('atproto_pds_url', pdsUrl, { httpOnly: true, maxAge: 600 }); + // 4. Store OAuth state in SurrealDB (not cookies, as they don't survive external redirects) + await storeOAuthState(state, code_verifier, pdsUrl); // 5. Construct the authorization URL const authUrl = new URL(authorizationEndpoint); diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000..4e0a14c --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,30 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser } from '@/lib/auth/session'; +import { Center, Paper, Stack, Title, Text } from '@mantine/core'; + +export default async function ChatPage() { + const user = await getCurrentUser(); + + // Redirect to login if not authenticated + if (!user) { + redirect('/login'); + } + + return ( +
+ + + + Welcome to Ponderants + + + Logged in as: {user.handle} + + + DID: {user.did} + + + +
+ ); +} diff --git a/db/schema.surql b/db/schema.surql new file mode 100644 index 0000000..f3900e0 --- /dev/null +++ b/db/schema.surql @@ -0,0 +1,91 @@ +-- -------------------------------------------------- +-- Ponderants :: SurrealDB Schema +-- -------------------------------------------------- + +-- -------------------------------------------------- +-- Access Control (JWT) +-- -------------------------------------------------- + +-- Define the JWT access method. This tells SurrealDB to trust +-- JWTs signed by our Next.js backend using the HS512 algorithm +-- and the secret key provided in the environment. +-- (Note: DEFINE TOKEN is deprecated as of 2.x) +DEFINE ACCESS app_jwt + ON DATABASE + TYPE JWT + ALGORITHM HS512 + KEY $env.SURREALDB_JWT_SECRET; + +-- -------------------------------------------------- +-- Table: user +-- -------------------------------------------------- + +-- Stores basic user information, cached from ATproto. +DEFINE TABLE user SCHEMAFULL; + +-- The user's decentralized identifier (DID) is their primary key. +DEFINE FIELD did ON TABLE user TYPE string + ASSERT $value != NONE; + +DEFINE FIELD handle ON TABLE user TYPE string; + +-- Ensure DIDs are unique. +DEFINE INDEX user_did_idx ON TABLE user COLUMNS did UNIQUE; + +-- -------------------------------------------------- +-- Table: node +-- -------------------------------------------------- + +-- Stores a single "thought node." This is the cache record for +-- the com.ponderants.node lexicon. +DEFINE TABLE node SCHEMAFULL + -- THIS IS THE CORE SECURITY MODEL: + -- Users can only perform actions on nodes where the + -- node's 'user_did' field matches the 'did' claim + -- from their validated JWT ('$token.did'). + PERMISSIONS + FOR select, create, update, delete + WHERE user_did = $token.did; + +-- Foreign key linking to the user table (via DID). +DEFINE FIELD user_did ON TABLE node TYPE string + ASSERT $value != NONE; + +-- The canonical URI of the record on the ATproto PDS. +DEFINE FIELD atp_uri ON TABLE node TYPE string; + +DEFINE FIELD title ON TABLE node TYPE string; +DEFINE FIELD body ON TABLE node TYPE string; + +-- The AI-generated vector embedding for the 'body'. +-- We use array for the vector. +DEFINE FIELD embedding ON TABLE node TYPE array; + +-- The 3D coordinates calculated by UMAP. +DEFINE FIELD coords_3d ON TABLE node TYPE array + -- Must be a 3-point array [x, y, z] or empty. + ASSERT $value = NONE OR array::len($value) = 3; + +-- Define the vector search index. +-- We use MTREE (or HNSW) for high-performance k-NN search. +-- The dimension (1536) MUST match the output of the +-- 'gemini-embedding-001' model. +DEFINE INDEX node_embedding_idx ON TABLE node FIELDS embedding MTREE DIMENSION 1536; + +-- -------------------------------------------------- +-- Relation: links_to +-- -------------------------------------------------- + +-- This is a graph edge table, relating (node)->(node). +DEFINE TABLE links_to SCHEMAFULL + -- Security for graph edges: A user can only create/view/delete + -- links between two nodes that BOTH belong to them. + PERMISSIONS + FOR select, create, delete + WHERE + (SELECT user_did FROM $from) = $token.did + AND + (SELECT user_did FROM $to) = $token.did; + +-- (No fields needed, it's a simple relation) +-- Example usage: RELATE (node:1)-[links_to]->(node:2); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ca3005 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + surrealdb: + image: surrealdb/surrealdb:latest + ports: + - "8000:8000" + command: + - start + - --log + - trace + - --user + - root + - --pass + - root + - memory + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/lib/auth/jwt.ts b/lib/auth/jwt.ts index 601a133..2636cd1 100644 --- a/lib/auth/jwt.ts +++ b/lib/auth/jwt.ts @@ -1,5 +1,14 @@ import jwt from 'jsonwebtoken'; +export interface UserSession { + did: string; + handle: string; + iss: string; + aud: string; + exp: number; + iat: number; +} + /** * Mints a new JWT for our application's session management. * This token is what SurrealDB will validate. @@ -34,3 +43,27 @@ export function mintSurrealJwt(did: string, handle: string): string { return token; } + +/** + * Verifies and decodes a JWT token. + * + * @param token - The JWT token to verify + * @returns The decoded user session, or null if invalid + */ +export function verifySurrealJwt(token: string): UserSession | null { + const secret = process.env.SURREALDB_JWT_SECRET; + if (!secret) { + throw new Error('SURREALDB_JWT_SECRET is not set in environment.'); + } + + try { + const decoded = jwt.verify(token, secret, { + algorithms: ['HS512'], + }) as UserSession; + + return decoded; + } catch (error) { + console.error('JWT verification failed:', error); + return null; + } +} diff --git a/lib/auth/oauth-state.ts b/lib/auth/oauth-state.ts new file mode 100644 index 0000000..f6e6706 --- /dev/null +++ b/lib/auth/oauth-state.ts @@ -0,0 +1,143 @@ +import Surreal from 'surrealdb'; + +const SURREALDB_URL = process.env.SURREALDB_URL; +const SURREALDB_NAMESPACE = process.env.SURREALDB_NS; +const SURREALDB_DATABASE = process.env.SURREALDB_DB; +const SURREALDB_USER = process.env.SURREALDB_USER; +const SURREALDB_PASS = process.env.SURREALDB_PASS; + +if (!SURREALDB_URL || !SURREALDB_NAMESPACE || !SURREALDB_DATABASE || !SURREALDB_USER || !SURREALDB_PASS) { + throw new Error('SurrealDB configuration is missing. Please set SURREALDB_URL, SURREALDB_NS, SURREALDB_DB, SURREALDB_USER, and SURREALDB_PASS in .env'); +} + +interface OAuthState { + state: string; + code_verifier: string; + pds_url: string; + created_at: string; +} + +/** + * Gets a SurrealDB connection for OAuth state management. + * This uses a separate, unauthenticated connection since OAuth happens before user auth. + */ +async function getDb() { + const db = new Surreal(); + await db.connect(SURREALDB_URL); + + // Sign in with root credentials for OAuth state management + await db.signin({ + username: SURREALDB_USER, + password: SURREALDB_PASS, + }); + + await db.use({ namespace: SURREALDB_NAMESPACE, database: SURREALDB_DATABASE }); + return db; +} + +/** + * Stores OAuth state parameters in SurrealDB for the duration of the OAuth flow. + * The state is used as the record ID for easy lookup. + * + * @param state - The OAuth state parameter (CSRF token) + * @param codeVerifier - The PKCE code verifier + * @param pdsUrl - The user's PDS URL + */ +export async function storeOAuthState( + state: string, + codeVerifier: string, + pdsUrl: string +): Promise { + const db = await getDb(); + + try { + // Store with a 10 minute TTL (OAuth flows should complete quickly) + const created_at = new Date().toISOString(); + console.log('[OAuth State] Storing state:', state); + + // Use CREATE with CONTENT to store all fields + const result = await db.create(`oauth_state:⟨${state}⟩`, { + state, + code_verifier: codeVerifier, + pds_url: pdsUrl, + created_at, + }); + console.log('[OAuth State] Stored successfully:', JSON.stringify(result, null, 2)); + } finally { + await db.close(); + } +} + +/** + * Retrieves and deletes OAuth state from SurrealDB. + * This ensures one-time use of the state parameter (security best practice). + * + * @param state - The OAuth state parameter to look up + * @returns The OAuth state data, or null if not found or expired + */ +export async function consumeOAuthState( + state: string +): Promise<{ codeVerifier: string; pdsUrl: string } | null> { + const db = await getDb(); + + try { + console.log('[OAuth State] Retrieving state:', state); + // Retrieve the state by record ID + const selectResult = await db.select(`oauth_state:⟨${state}⟩`); + console.log('[OAuth State] Select result:', JSON.stringify(selectResult, null, 2)); + + // db.select() returns an array when selecting a specific record ID + const result = Array.isArray(selectResult) ? selectResult[0] : selectResult; + console.log('[OAuth State] Retrieved record:', JSON.stringify(result, null, 2)); + + if (!result) { + console.log('[OAuth State] No result found for state:', state); + return null; + } + + // Check if expired (older than 10 minutes) + const createdAt = new Date(result.created_at); + const now = new Date(); + const ageMinutes = (now.getTime() - createdAt.getTime()) / 1000 / 60; + console.log('[OAuth State] State age:', ageMinutes, 'minutes'); + + if (ageMinutes > 10) { + console.log('[OAuth State] State expired, deleting'); + // Delete expired state + await db.delete(`oauth_state:⟨${state}⟩`); + return null; + } + + // Delete the state (one-time use) + console.log('[OAuth State] Deleting state (one-time use)'); + await db.delete(`oauth_state:⟨${state}⟩`); + + return { + codeVerifier: result.code_verifier, + pdsUrl: result.pds_url, + }; + } finally { + await db.close(); + } +} + +/** + * Cleans up expired OAuth states (should be called periodically). + * In production, this would be a cron job or scheduled task. + */ +export async function cleanupExpiredOAuthStates(): Promise { + const db = await getDb(); + + try { + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + + const result = await db.query<[OAuthState[]]>( + 'DELETE FROM oauth_state WHERE created_at < $cutoff RETURN BEFORE', + { cutoff: tenMinutesAgo } + ); + + return result[0]?.length || 0; + } finally { + await db.close(); + } +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..b8be2f6 --- /dev/null +++ b/lib/auth/session.ts @@ -0,0 +1,19 @@ +import { cookies } from 'next/headers'; +import { verifySurrealJwt, type UserSession } from './jwt'; + +/** + * Gets the current authenticated user from the session cookie. + * This function should be called from Server Components or API routes. + * + * @returns The user session if authenticated, null otherwise + */ +export async function getCurrentUser(): Promise { + const cookieStore = await cookies(); + const authCookie = cookieStore.get('ponderants-auth'); + + if (!authCookie?.value) { + return null; + } + + return verifySurrealJwt(authCookie.value); +} diff --git a/magnitude.config.ts b/magnitude.config.ts index cd4e1fb..f1ccb4a 100644 --- a/magnitude.config.ts +++ b/magnitude.config.ts @@ -4,4 +4,6 @@ export default { url: 'http://localhost:3000', // We will configure magnitude to find tests in this directory tests: 'tests/magnitude/**/*.mag.ts', + // Run tests in headless mode to avoid window focus issues + headless: true, };