diff --git a/app/(auth)/callback/route.ts b/app/(auth)/callback/route.ts index 65488df..02aac63 100644 --- a/app/(auth)/callback/route.ts +++ b/app/(auth)/callback/route.ts @@ -41,31 +41,12 @@ export async function GET(request: NextRequest): Promise { }, ); - const { data, error } = await supabase.auth.exchangeCodeForSession(code); + const { error } = await supabase.auth.exchangeCodeForSession(code); if (error) { return NextResponse.redirect(new URL('/login?error=auth', baseUrl)); } - // After Twitter login, redirect to the platform connection flow if not yet connected. - // This ensures sign-in with Twitter also grants posting permissions (tweet.write). - if (data.user?.app_metadata?.provider === 'x' && next === '/app') { - const { data: existing } = await supabase - .from('platform_connections') - .select('id') - .eq('user_id', data.user.id) - .eq('platform', 'twitter') - .maybeSingle(); - - if (!existing) { - const response = NextResponse.redirect(new URL('/api/connect/twitter/authorize', baseUrl)); - for (const { name, value, options } of pendingCookies) { - response.cookies.set(name, value, options); - } - return response; - } - } - const response = NextResponse.redirect(new URL(next, baseUrl)); for (const { name, value, options } of pendingCookies) { diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 719d9e1..a54e4f9 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -68,7 +68,7 @@ function LoginForm() { router.push('/app'); } - async function handleOAuth(provider: 'google' | 'x' | 'linkedin_oidc') { + async function handleOAuth(provider: 'google') { await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/callback` }, @@ -180,22 +180,18 @@ function LoginForm() { Google - + - {/* handleOAuth('linkedin_oidc')}*/} - {/* className="w-full"*/} - {/*>*/} - {/* */} - {/* LinkedIn*/} - {/**/}

diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index f3b47a6..50d5960 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -80,7 +80,7 @@ export default function RegisterPage() { setIsLoading(false); } - async function handleOAuth(provider: 'google' | 'x' | 'linkedin_oidc') { + async function handleOAuth(provider: 'google') { await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/callback` }, @@ -191,22 +191,18 @@ export default function RegisterPage() { Google - + - {/* handleOAuth('linkedin_oidc')}*/} - {/* className="w-full"*/} - {/*>*/} - {/* */} - {/* LinkedIn*/} - {/**/}

diff --git a/app/api/connect/linkedin/authorize/route.ts b/app/api/connect/linkedin/authorize/route.ts index 0864260..4ed4ce1 100644 --- a/app/api/connect/linkedin/authorize/route.ts +++ b/app/api/connect/linkedin/authorize/route.ts @@ -1,13 +1,14 @@ import { randomBytes } from 'crypto'; -import { NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { getAppUrl } from '@/lib/twitter'; const LINKEDIN_AUTHORIZE_URL = 'https://www.linkedin.com/oauth/v2/authorization'; const COOKIE_MAX_AGE = 600; // 10 minutes -export async function GET(): Promise { +export async function GET(request: NextRequest): Promise { + const intent = request.nextUrl.searchParams.get('intent'); const state = randomBytes(16).toString('hex'); const params = new URLSearchParams({ @@ -28,5 +29,14 @@ export async function GET(): Promise { path: '/', }); + if (intent === 'auth') { + response.cookies.set('linkedin_auth_intent', 'true', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: COOKIE_MAX_AGE, + path: '/', + }); + } + return response; } diff --git a/app/api/connect/linkedin/route.ts b/app/api/connect/linkedin/route.ts index 32be663..2907de5 100644 --- a/app/api/connect/linkedin/route.ts +++ b/app/api/connect/linkedin/route.ts @@ -1,28 +1,30 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createServerClient } from '@/lib/supabase/server'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; import { getAppUrl } from '@/lib/twitter'; const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'; const LINKEDIN_USERINFO_URL = 'https://api.linkedin.com/v2/userinfo'; -function redirectWithError(_request: NextRequest, error: string): NextResponse { - return NextResponse.redirect( - new URL(`/app/settings/accounts?error=${error}`, getAppUrl()), - ); +function redirectWithError(_request: NextRequest, error: string, isAuthIntent = false): NextResponse { + const path = isAuthIntent + ? `/login?error=linkedin_auth` + : `/app/settings/accounts?error=${error}`; + return NextResponse.redirect(new URL(path, getAppUrl())); } export async function GET(request: NextRequest): Promise { const code = request.nextUrl.searchParams.get('code'); const state = request.nextUrl.searchParams.get('state'); const storedState = request.cookies.get('linkedin_state')?.value; + const authIntent = request.cookies.get('linkedin_auth_intent')?.value === 'true'; if (!code || !storedState) { - return redirectWithError(request, 'missing_params'); + return redirectWithError(request, 'missing_params', authIntent); } if (state !== storedState) { - return redirectWithError(request, 'state_mismatch'); + return redirectWithError(request, 'state_mismatch', authIntent); } // Exchange code for tokens @@ -39,13 +41,13 @@ export async function GET(request: NextRequest): Promise { }); if (!tokenResponse.ok) { - return redirectWithError(request, 'token_exchange'); + return redirectWithError(request, 'token_exchange', authIntent); } const tokens = await tokenResponse.json(); if (!tokens.access_token) { - return redirectWithError(request, 'token_exchange'); + return redirectWithError(request, 'token_exchange', authIntent); } // Fetch user profile via OpenID Connect userinfo @@ -54,12 +56,98 @@ export async function GET(request: NextRequest): Promise { }); if (!userResponse.ok) { - return redirectWithError(request, 'profile_fetch'); + return redirectWithError(request, 'profile_fetch', authIntent); } const linkedInUser = await userResponse.json(); - // Store in database + if (authIntent) { + // Branch B: find/create Supabase user, upsert connection, generate magic link + const serviceClient = createServiceRoleClient(); + + // Check for existing platform connection + const { data: existingConnection } = await serviceClient + .from('platform_connections') + .select('user_id') + .eq('platform', 'linkedin') + .eq('platform_user_id', linkedInUser.sub) + .maybeSingle(); + + let userId: string; + let userEmail: string; + + if (existingConnection) { + userId = existingConnection.user_id; + const { data: adminUserData } = await serviceClient.auth.admin.getUserById(userId); + userEmail = adminUserData.user?.email ?? `linkedin_${linkedInUser.sub}@linkedin.socio.app`; + } else { + // Use real LinkedIn email if available, otherwise synthetic + userEmail = linkedInUser.email ?? `linkedin_${linkedInUser.sub}@linkedin.socio.app`; + const { data: newUserData, error: createError } = await serviceClient.auth.admin.createUser({ + email: userEmail, + email_confirm: true, + user_metadata: { + full_name: linkedInUser.name, + avatar_url: linkedInUser.picture, + }, + }); + + if (createError || !newUserData.user) { + return redirectWithError(request, 'user_create', true); + } + + userId = newUserData.user.id; + } + + // Upsert platform connection + const { error: upsertError } = await serviceClient + .from('platform_connections') + .upsert( + { + user_id: userId, + platform: 'linkedin', + access_token: tokens.access_token, + refresh_token: tokens.refresh_token ?? null, + token_expires_at: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() + : null, + platform_user_id: linkedInUser.sub ?? null, + platform_username: linkedInUser.email ?? null, + platform_display_name: linkedInUser.name ?? null, + platform_avatar_url: linkedInUser.picture ?? null, + scopes: tokens.scope ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,platform' }, + ); + + if (upsertError) { + return redirectWithError(request, 'db_error', true); + } + + // Generate magic link to establish session + const { data: linkData, error: linkError } = await serviceClient.auth.admin.generateLink({ + type: 'magiclink', + email: userEmail, + options: { redirectTo: getAppUrl() + '/app' }, + }); + + if (linkError || !linkData.properties?.hashed_token) { + return redirectWithError(request, 'magic_link', true); + } + + const confirmUrl = new URL('/auth/confirm', getAppUrl()); + confirmUrl.searchParams.set('token_hash', linkData.properties.hashed_token); + confirmUrl.searchParams.set('next', '/app'); + + const response = NextResponse.redirect(confirmUrl); + response.cookies.delete('linkedin_state'); + response.cookies.delete('linkedin_auth_intent'); + + return response; + } + + // Branch A: require logged-in user, upsert connection, redirect to settings const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); diff --git a/app/api/connect/twitter/authorize/route.ts b/app/api/connect/twitter/authorize/route.ts index 2e1e409..a0df3a5 100644 --- a/app/api/connect/twitter/authorize/route.ts +++ b/app/api/connect/twitter/authorize/route.ts @@ -1,13 +1,14 @@ import { createHash, randomBytes } from 'crypto'; -import { NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { getTwitterCallbackUrl, getAppUrl } from '@/lib/twitter'; const TWITTER_AUTHORIZE_URL = 'https://x.com/i/oauth2/authorize'; const COOKIE_MAX_AGE = 600; // 10 minutes -export async function GET(): Promise { +export async function GET(request: NextRequest): Promise { + const intent = request.nextUrl.searchParams.get('intent'); const codeVerifier = randomBytes(32).toString('base64url'); const codeChallenge = createHash('sha256') .update(codeVerifier) @@ -42,5 +43,14 @@ export async function GET(): Promise { path: '/', }); + if (intent === 'auth') { + response.cookies.set('twitter_auth_intent', 'true', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: COOKIE_MAX_AGE, + path: '/', + }); + } + return response; } diff --git a/app/api/connect/twitter/route.ts b/app/api/connect/twitter/route.ts index 8270084..fa298f0 100644 --- a/app/api/connect/twitter/route.ts +++ b/app/api/connect/twitter/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getTwitterCallbackUrl, getAppUrl } from '@/lib/twitter'; -import { createServerClient } from '@/lib/supabase/server'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; const TWITTER_TOKEN_URL = 'https://api.x.com/2/oauth2/token'; const TWITTER_USER_URL = 'https://api.x.com/2/users/me?user.fields=profile_image_url'; @@ -13,28 +13,31 @@ function getBasicAuthHeader(): string { return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; } -function redirectWithError(_request: NextRequest, error: string): NextResponse { - return NextResponse.redirect( - new URL(`/app/settings/accounts?error=${error}`, getAppUrl()), - ); +function redirectWithError(_request: NextRequest, error: string, isAuthIntent = false): NextResponse { + const path = isAuthIntent + ? `/login?error=twitter_auth` + : `/app/settings/accounts?error=${error}`; + return NextResponse.redirect(new URL(path, getAppUrl())); } export async function GET(request: NextRequest): Promise { const errorFromX = request.nextUrl.searchParams.get('error'); + const authIntent = request.cookies.get('twitter_auth_intent')?.value === 'true'; + if (errorFromX) { - return redirectWithError(request, `twitter_${errorFromX}`); + return redirectWithError(request, `twitter_${errorFromX}`, authIntent); } const code = request.nextUrl.searchParams.get('code'); const codeVerifier = request.cookies.get('twitter_code_verifier')?.value; if (!code || !codeVerifier) { - return redirectWithError(request, 'missing_params'); + return redirectWithError(request, 'missing_params', authIntent); } const redirectUri = getTwitterCallbackUrl(); if (!redirectUri) { - return redirectWithError(request, 'twitter_config'); + return redirectWithError(request, 'twitter_config', authIntent); } // Exchange code for tokens @@ -53,13 +56,13 @@ export async function GET(request: NextRequest): Promise { }); if (!tokenResponse.ok) { - return redirectWithError(request, 'token_exchange'); + return redirectWithError(request, 'token_exchange', authIntent); } const tokens = await tokenResponse.json(); if (!tokens.access_token) { - return redirectWithError(request, 'token_exchange'); + return redirectWithError(request, 'token_exchange', authIntent); } // Fetch user profile @@ -68,12 +71,97 @@ export async function GET(request: NextRequest): Promise { }); if (!userResponse.ok) { - return redirectWithError(request, 'profile_fetch'); + return redirectWithError(request, 'profile_fetch', authIntent); } const { data: twitterUser } = await userResponse.json(); - // Store in database + if (authIntent) { + // Branch B: find/create Supabase user, upsert connection, generate magic link + const serviceClient = createServiceRoleClient(); + + // Check for existing platform connection + const { data: existingConnection } = await serviceClient + .from('platform_connections') + .select('user_id') + .eq('platform', 'twitter') + .eq('platform_user_id', twitterUser.id) + .maybeSingle(); + + let userId: string; + let userEmail: string; + + if (existingConnection) { + userId = existingConnection.user_id; + const { data: adminUserData } = await serviceClient.auth.admin.getUserById(userId); + userEmail = adminUserData.user?.email ?? `twitter_${twitterUser.id}@x.socio.app`; + } else { + userEmail = `twitter_${twitterUser.id}@x.socio.app`; + const { data: newUserData, error: createError } = await serviceClient.auth.admin.createUser({ + email: userEmail, + email_confirm: true, + user_metadata: { + full_name: twitterUser.name, + avatar_url: twitterUser.profile_image_url, + }, + }); + + if (createError || !newUserData.user) { + return redirectWithError(request, 'user_create', true); + } + + userId = newUserData.user.id; + } + + // Upsert platform connection + const { error: upsertError } = await serviceClient + .from('platform_connections') + .upsert( + { + user_id: userId, + platform: 'twitter', + access_token: tokens.access_token, + refresh_token: tokens.refresh_token ?? null, + token_expires_at: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() + : null, + platform_user_id: twitterUser.id, + platform_username: twitterUser.username, + platform_display_name: twitterUser.name, + platform_avatar_url: twitterUser.profile_image_url ?? null, + scopes: tokens.scope ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,platform' }, + ); + + if (upsertError) { + return redirectWithError(request, 'db_error', true); + } + + // Generate magic link to establish session + const { data: linkData, error: linkError } = await serviceClient.auth.admin.generateLink({ + type: 'magiclink', + email: userEmail, + options: { redirectTo: getAppUrl() + '/app' }, + }); + + if (linkError || !linkData.properties?.hashed_token) { + return redirectWithError(request, 'magic_link', true); + } + + const confirmUrl = new URL('/auth/confirm', getAppUrl()); + confirmUrl.searchParams.set('token_hash', linkData.properties.hashed_token); + confirmUrl.searchParams.set('next', '/app'); + + const response = NextResponse.redirect(confirmUrl); + response.cookies.delete('twitter_code_verifier'); + response.cookies.delete('twitter_auth_intent'); + + return response; + } + + // Branch A: require logged-in user, upsert connection, redirect to settings const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 0000000..c1308a7 --- /dev/null +++ b/app/auth/confirm/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { createServerClient } from '@supabase/ssr'; + +interface ICookieEntry { + name: string; + value: string; + options: Record; +} + +export async function GET(request: NextRequest): Promise { + const { searchParams } = request.nextUrl; + const tokenHash = searchParams.get('token_hash'); + const next = searchParams.get('next') ?? '/app'; + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; + + if (!tokenHash) { + return NextResponse.redirect(new URL('/login?error=auth', baseUrl)); + } + + const pendingCookies: ICookieEntry[] = []; + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookies) { + for (const { name, value, options } of cookies) { + pendingCookies.push({ name, value, options: options as Record }); + } + }, + }, + }, + ); + + const { error } = await supabase.auth.verifyOtp({ + type: 'magiclink', + token_hash: tokenHash, + }); + + if (error) { + return NextResponse.redirect(new URL('/login?error=auth', baseUrl)); + } + + const response = NextResponse.redirect(new URL(next, baseUrl)); + for (const { name, value, options } of pendingCookies) { + response.cookies.set(name, value, options); + } + + return response; +} diff --git a/docs/auth.md b/docs/auth.md index 3621527..d84a321 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -6,7 +6,7 @@ |---|---|---| | Email + Password | Supabase Auth | Standard sign up and sign in | | Google OAuth | Supabase Auth | One-click sign in with Google account | -| Twitter OAuth | Supabase Auth | One-click sign in with Twitter/X account | +| Twitter OAuth | Direct PKCE flow | Single prompt covers auth + platform connection | | LinkedIn OIDC | Supabase Auth | One-click sign in with LinkedIn account | ## Sign-Up Flow @@ -21,7 +21,9 @@ There is no onboarding wizard. New users without an active subscription are redi ## OAuth Callback -After Google, Twitter, or LinkedIn OAuth, Supabase redirects to `/callback`. The callback route exchanges the auth code for a session, then redirects the user into the app. +After Google or LinkedIn OAuth, Supabase redirects to `/callback`. The callback route exchanges the auth code for a session, then redirects the user into the app. + +Twitter uses a separate direct PKCE flow (see Platform Connections). The Twitter button on login/register links directly to `/api/connect/twitter/authorize?intent=auth`, bypassing Supabase OAuth entirely. After Twitter auth, the callback finds or creates a Supabase user, upserts the platform connection, and generates a magic link to establish the session — one OAuth prompt handles both authentication and connection. ## Password Reset Flow diff --git a/docs/platform-connections.md b/docs/platform-connections.md index 19ee445..3ca6c27 100644 --- a/docs/platform-connections.md +++ b/docs/platform-connections.md @@ -14,12 +14,24 @@ Users connect their social media accounts (Twitter/X and LinkedIn) to enable pub ## OAuth Flows ### Twitter/X -1. User clicks "Connect Twitter" in Settings → Accounts -2. App generates a PKCE code challenge and state, stores them temporarily -3. User is redirected to Twitter's authorization page -4. Twitter redirects to `/api/connect/twitter` with the auth code -5. App exchanges code for access token + refresh token using the code verifier -6. Connection is saved in `platform_connections` + +The Twitter flow serves two purposes depending on context: + +**From Settings → Accounts (connect only):** +1. User clicks "Connect Twitter" +2. App generates a PKCE code challenge, redirects to Twitter authorization +3. Twitter redirects to `/api/connect/twitter` with the auth code +4. App exchanges code for tokens, fetches profile, saves connection in `platform_connections` +5. User is redirected back to Settings → Accounts + +**From Login / Register (auth + connect):** +1. User clicks the Twitter button — links to `/api/connect/twitter/authorize?intent=auth` +2. App sets a `twitter_auth_intent` cookie alongside the PKCE verifier, then redirects to Twitter +3. Twitter redirects to `/api/connect/twitter` +4. App exchanges code for tokens and fetches the Twitter profile +5. App looks up an existing `platform_connections` row by `platform_user_id`; if none exists, creates a new Supabase user with a synthetic email (`twitter_@x.socio.app`) +6. Connection is upserted in `platform_connections` +7. App generates a Supabase magic link for the user's email and redirects to it — Supabase processes the link and establishes a session, landing the user on `/app` ### LinkedIn 1. User clicks "Connect LinkedIn"