Replace entire repo content with code from /root/shahikitchen-google/

This commit is contained in:
root
2026-07-03 02:51:15 +00:00
parent c8b7dc95e4
commit 8ccf033329
79 changed files with 7611 additions and 439 deletions
+51
View File
@@ -0,0 +1,51 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { cookies } from 'next/headers';
export const ADMIN_SESSION_COOKIE = 'shahi_admin_session';
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
function getSessionSecret(): string {
return process.env.ADMIN_SESSION_SECRET ?? 'shahi-kitchen-admin-dev-secret';
}
export function createAdminSessionToken(): string {
const issuedAt = Date.now().toString();
const payload = `admin:${issuedAt}`;
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
return `${payload}.${signature}`;
}
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
if (!token) return false;
const [payload, signature] = token.split('.');
if (!payload || !signature) return false;
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length) return false;
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return false;
const [, issuedAtRaw] = payload.split(':');
const issuedAt = Number(issuedAtRaw);
if (!Number.isFinite(issuedAt)) return false;
return Date.now() - issuedAt <= SESSION_MAX_AGE_MS;
}
export async function isAdminAuthenticated(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
return verifyAdminSessionToken(token);
}
export function getAdminSessionCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_MAX_AGE_MS / 1000,
};
}
+87
View File
@@ -0,0 +1,87 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { cookies } from 'next/headers';
export const CUSTOMER_SESSION_COOKIE = 'shahi_customer_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export interface CustomerSession {
email: string;
name?: string;
}
function getSessionSecret(): string {
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
}
function encodeEmail(email: string): string {
return Buffer.from(email, 'utf8').toString('base64url');
}
function decodeEmail(encoded: string): string | null {
try {
return Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
return null;
}
}
export function createCustomerSessionToken(session: CustomerSession): string {
const issuedAt = Date.now().toString();
const namePart = session.name ? `:${encodeEmail(session.name)}` : '';
const payload = `customer:${encodeEmail(session.email)}${namePart}:${issuedAt}`;
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
return `${payload}.${signature}`;
}
export function verifyCustomerSessionToken(token: string | undefined | null): CustomerSession | null {
if (!token) return null;
const [payload, signature] = token.split('.');
if (!payload || !signature) return null;
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return null;
const parts = payload.split(':');
if (parts[0] !== 'customer' || parts.length < 3) return null;
const issuedAt = Number(parts[parts.length - 1]);
if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > SESSION_MAX_AGE_MS) return null;
const email = decodeEmail(parts[1]);
if (!email) return null;
let name: string | undefined;
if (parts.length === 4) {
name = decodeEmail(parts[2]) ?? undefined;
}
return { email, name };
}
export async function getCustomerSession(): Promise<CustomerSession | null> {
const cookieStore = await cookies();
const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value;
return verifyCustomerSessionToken(token);
}
export function getCustomerSessionCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_MAX_AGE_MS / 1000,
};
}
export function getClearedCustomerSessionCookieOptions() {
return {
...getCustomerSessionCookieOptions(),
maxAge: 0,
expires: new Date(0),
};
}
+95
View File
@@ -0,0 +1,95 @@
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(): string {
return process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
}
export function getGoogleRedirectUri(): string {
return `${getSiteOrigin()}/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): string {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: getGoogleRedirectUri(),
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,
): 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(),
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,
};
}
+28
View File
@@ -0,0 +1,28 @@
const OAUTH_RETURN_COOKIE = 'shahi_google_oauth_return_to';
const DEFAULT_RETURN_PATH = '/account';
const SAFE_PATH_PATTERN = /^\/[a-zA-Z0-9/_-]*$/;
export function getOAuthReturnCookieName(): string {
return OAUTH_RETURN_COOKIE;
}
/** Only same-origin relative paths (optionally with query) are allowed after OAuth. */
export function sanitizeOAuthReturnPath(value: string | null | undefined): string {
if (!value) return DEFAULT_RETURN_PATH;
const trimmed = value.trim();
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('://')) {
return DEFAULT_RETURN_PATH;
}
const withoutHash = trimmed.split('#')[0] ?? trimmed;
const queryIndex = withoutHash.indexOf('?');
const pathname = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
const search = queryIndex === -1 ? '' : withoutHash.slice(queryIndex);
if (!pathname || !SAFE_PATH_PATTERN.test(pathname)) {
return DEFAULT_RETURN_PATH;
}
return `${pathname}${search}`;
}
+6
View File
@@ -0,0 +1,6 @@
import { requireMenuManager } from './require-menu-manager';
/** Menu Management routes — requires an allowlisted Google customer session. */
export async function requireAdmin(): Promise<import('next/server').NextResponse | null> {
return requireMenuManager();
}
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { isMenuManagerEmail } from '@/domain/auth/menu-managers';
import { getCustomerSession } from './customer-session';
export async function getMenuManagerSession() {
const session = await getCustomerSession();
if (!session || !isMenuManagerEmail(session.email)) return null;
return session;
}
export async function requireMenuManager(): Promise<NextResponse | null> {
const session = await getMenuManagerSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return null;
}