feat: 3-click SSO registration - unified /auth page, Google/Apple/GitHub OAuth, email fallback, provider model

This commit is contained in:
root
2026-06-15 12:42:03 +02:00
parent 64ae34e9c9
commit d194e295ad
12 changed files with 1150 additions and 294 deletions
@@ -0,0 +1,157 @@
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);
}