feat: Complete Step 3 & 4 - OAuth + SurrealDB schema
Step 3: ATproto OAuth + SurrealDB JWT - Implement database-backed OAuth state storage (lib/auth/oauth-state.ts) - Add session helpers for JWT decoding (lib/auth/session.ts) - Fix OAuth callback to properly handle state retrieval - Create /chat page displaying authenticated user handle - Configure headless mode for Magnitude testing Step 4: SurrealDB Schema & Permissions - Define JWT-based access control (HS512 algorithm) - Create user table with DID-based identity - Create node table with row-level security (users can only access their own data) - Create links_to relation table for graph edges - Define vector search index (1536 dimensions for gemini-embedding-001) - Add Docker Compose for local SurrealDB development 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
app/chat/page.tsx
Normal file
30
app/chat/page.tsx
Normal file
@@ -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 (
|
||||
<Center h="100vh">
|
||||
<Paper w={600} p="xl">
|
||||
<Stack>
|
||||
<Title order={1} ta="center">
|
||||
Welcome to Ponderants
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed" size="sm">
|
||||
Logged in as: <Text component="span" fw={700} c="white">{user.handle}</Text>
|
||||
</Text>
|
||||
<Text ta="center" c="dimmed" size="xs">
|
||||
DID: {user.did}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user