158 lines
4.2 KiB
TypeScript
158 lines
4.2 KiB
TypeScript
|
|
import { NextRequest, NextResponse } from "next/server";
|
||
|
|
import { cookies } from "next/headers";
|
||
|
|
import {
|
||
|
|
type Provider,
|
||
|
|
exchangeCode,
|
||
|
|
findOrCreateOAuthUser,
|
||
|
|
verifyState,
|
||
|
|
} from "@/lib/oauth";
|
||
|
|
import { signJWT } from "@/lib/auth";
|
||
|
|
|
||
|
|
const VALID_PROVIDERS = ["google", "apple", "github"];
|
||
|
|
|
||
|
|
async function handleCallback(
|
||
|
|
req: NextRequest,
|
||
|
|
provider: string
|
||
|
|
): Promise<NextResponse> {
|
||
|
|
try {
|
||
|
|
// Grab code and state from query params or POST body (Apple uses form_post)
|
||
|
|
const url = new URL(req.url);
|
||
|
|
let code = url.searchParams.get("code");
|
||
|
|
let stateParam = url.searchParams.get("state");
|
||
|
|
|
||
|
|
// Apple may POST form data — try reading POST body
|
||
|
|
if (!code) {
|
||
|
|
try {
|
||
|
|
const contentType = req.headers.get("content-type") || "";
|
||
|
|
if (contentType.includes("form")) {
|
||
|
|
const formData = await req.formData();
|
||
|
|
code = formData.get("code") as string | null;
|
||
|
|
stateParam = formData.get("state") as string | null;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// not a form POST, that's fine
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!code) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{ error: "Missing authorization code" },
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!stateParam) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{ error: "Missing state parameter" },
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify state from cookie (CSRF protection)
|
||
|
|
const cookieStore = await cookies();
|
||
|
|
const storedState = cookieStore.get("oauth_state")?.value;
|
||
|
|
|
||
|
|
if (!storedState) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{
|
||
|
|
error: "Missing state cookie. Please start the OAuth flow again.",
|
||
|
|
},
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const statePayload = await verifyState(stateParam);
|
||
|
|
if (!statePayload) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{
|
||
|
|
error:
|
||
|
|
"Invalid or expired state. Please start the OAuth flow again.",
|
||
|
|
},
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify the state and provider match
|
||
|
|
if (statePayload.provider !== provider) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{ error: "State/provider mismatch" },
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear the state cookie
|
||
|
|
cookieStore.set("oauth_state", "", {
|
||
|
|
httpOnly: true,
|
||
|
|
path: "/",
|
||
|
|
maxAge: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Build the redirect URI (must match the one used in the authorization request)
|
||
|
|
const baseUrl = `${url.protocol}//${url.host}`;
|
||
|
|
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
|
||
|
|
|
||
|
|
// Exchange code for user info
|
||
|
|
const providerUser = await exchangeCode(
|
||
|
|
provider as Provider,
|
||
|
|
code,
|
||
|
|
redirectUri
|
||
|
|
);
|
||
|
|
|
||
|
|
// Find or create user in database
|
||
|
|
const user = await findOrCreateOAuthUser(
|
||
|
|
provider as Provider,
|
||
|
|
providerUser
|
||
|
|
);
|
||
|
|
|
||
|
|
// Generate JWT
|
||
|
|
const token = await signJWT({
|
||
|
|
userId: user.id,
|
||
|
|
email: user.email,
|
||
|
|
isPremium: user.isPremium,
|
||
|
|
isPro: user.isPro,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Redirect to frontend callback page with token
|
||
|
|
const frontendUrl = `${baseUrl}/mobile/auth/callback?token=${encodeURIComponent(token)}`;
|
||
|
|
return NextResponse.redirect(frontendUrl);
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`${provider} OAuth callback error:`, error);
|
||
|
|
const message =
|
||
|
|
error instanceof Error ? error.message : "OAuth callback failed";
|
||
|
|
return NextResponse.json({ error: message }, { status: 500 });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function GET(
|
||
|
|
req: NextRequest,
|
||
|
|
{ params }: { params: Promise<{ provider: string }> }
|
||
|
|
) {
|
||
|
|
const { provider } = await params;
|
||
|
|
if (!VALID_PROVIDERS.includes(provider)) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{
|
||
|
|
error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
|
||
|
|
},
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return handleCallback(req, provider);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Apple OAuth may use form_post response_mode, which sends a POST. */
|
||
|
|
export async function POST(
|
||
|
|
req: NextRequest,
|
||
|
|
{ params }: { params: Promise<{ provider: string }> }
|
||
|
|
) {
|
||
|
|
const { provider } = await params;
|
||
|
|
if (!VALID_PROVIDERS.includes(provider)) {
|
||
|
|
return NextResponse.json(
|
||
|
|
{
|
||
|
|
error: `Unsupported provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
|
||
|
|
},
|
||
|
|
{ status: 400 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return handleCallback(req, provider);
|
||
|
|
}
|