112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
|
|
|
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
|
|
|
export function isGoogleOAuthConfigured(): boolean {
|
|
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
}
|
|
|
|
export function getSiteOrigin(req?: Request): string {
|
|
const configured = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://shahikitchen.se';
|
|
if (req) {
|
|
try {
|
|
const h = req.headers;
|
|
let host = h.get('x-forwarded-host') || h.get('host');
|
|
if (host) {
|
|
host = host.split(':')[0]; // strip port
|
|
// Only use www variant if explicitly requested; otherwise always canonical production domain
|
|
// This prevents localhost, IP, or internal ports from leaking into redirect_uri
|
|
if (host === 'www.shahikitchen.se') {
|
|
return 'https://www.shahikitchen.se';
|
|
}
|
|
return configured;
|
|
}
|
|
} catch {}
|
|
}
|
|
return configured;
|
|
}
|
|
|
|
export function getGoogleRedirectUri(req?: Request): string {
|
|
return `${getSiteOrigin(req)}/api/auth/callback/google`;
|
|
}
|
|
|
|
function getOAuthStateSecret(): string {
|
|
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
|
|
}
|
|
|
|
export function createOAuthState(): string {
|
|
const nonce = randomBytes(16).toString('hex');
|
|
const signature = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
|
|
return `${nonce}.${signature}`;
|
|
}
|
|
|
|
export function verifyOAuthState(state: string | null | undefined): boolean {
|
|
if (!state) return false;
|
|
const [nonce, signature] = state.split('.');
|
|
if (!nonce || !signature) return false;
|
|
|
|
const expected = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
|
|
const sigBuffer = Buffer.from(signature);
|
|
const expectedBuffer = Buffer.from(expected);
|
|
if (sigBuffer.length !== expectedBuffer.length) return false;
|
|
return timingSafeEqual(sigBuffer, expectedBuffer);
|
|
}
|
|
|
|
export function buildGoogleAuthUrl(state: string, req?: Request): string {
|
|
const params = new URLSearchParams({
|
|
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
redirect_uri: getGoogleRedirectUri(req),
|
|
response_type: 'code',
|
|
scope: 'openid email profile',
|
|
access_type: 'online',
|
|
prompt: 'select_account',
|
|
state,
|
|
});
|
|
|
|
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
}
|
|
|
|
interface GoogleTokenResponse {
|
|
access_token?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface GoogleUserInfo {
|
|
email?: string;
|
|
email_verified?: boolean;
|
|
name?: string;
|
|
}
|
|
|
|
export async function fetchGoogleUserFromCode(
|
|
code: string,
|
|
req?: Request,
|
|
): Promise<{ email: string; name?: string } | null> {
|
|
const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
code,
|
|
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
redirect_uri: getGoogleRedirectUri(req),
|
|
grant_type: 'authorization_code',
|
|
}),
|
|
});
|
|
|
|
const tokenData = (await tokenResponse.json()) as GoogleTokenResponse;
|
|
if (!tokenResponse.ok || !tokenData.access_token) return null;
|
|
|
|
const userResponse = await fetch(GOOGLE_USERINFO_URL, {
|
|
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
});
|
|
|
|
const userData = (await userResponse.json()) as GoogleUserInfo;
|
|
if (!userResponse.ok || !userData.email || userData.email_verified === false) return null;
|
|
|
|
return {
|
|
email: userData.email,
|
|
name: userData.name,
|
|
};
|
|
} |