codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
EDU›Central admin platform — many domains behind one hub›Step 5

Step 5

OAuth 2 providers + allow-list

0 views

OAuth 2 providers + allow-list

Admin apps do not need full sign-up flows. Delegate identity to Kakao · Naver · Google and accept only whitelisted emails.

1. Flow

1. /login → click [sign in with Kakao]
2. GET /api/auth/kakao → 302 to Kakao
3. Kakao callback → /api/auth/kakao/callback
4. code → access_token → fetch user email
5. email must be in allow-list
6. issue JWT in HTTP-only cookie
7. redirect /admin/dashboard

2. Allow-list

const raw = process.env.ADMIN_ALLOWED_EMAILS ?? '';
export const ALLOWED_EMAILS = new Set(
  raw.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)
);
export function isAllowedEmail(email: string): boolean {
  return ALLOWED_EMAILS.has(email.toLowerCase());
}

.env keeps it simple; departures are reflected on next deploy.

3. Kakao provider

export async function exchangeKakaoCode(code: string) {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: requireEnv('KAKAO_CLIENT_ID'),
    client_secret: requireEnv('KAKAO_CLIENT_SECRET'),
    redirect_uri: requireEnv('KAKAO_REDIRECT_URI'),
    code,
  });
  const tokenRes = await fetch('https://kauth.kakao.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });
  const { access_token } = await tokenRes.json();
  const userRes = await fetch('https://kapi.kakao.com/v2/user/me', {
    headers: { Authorization: `Bearer ${access_token}` },
  });
  const user = await userRes.json();
  return {
    email: user.kakao_account?.email as string | undefined,
    nickname: user.kakao_account?.profile?.nickname,
    providerId: String(user.id),
  };
}

4. Callback handler

export async function GET(req: NextRequest) {
  const code = req.nextUrl.searchParams.get('code');
  if (!code) return NextResponse.redirect(new URL('/login?e=no_code', req.url));

  try {
    const user = await exchangeKakaoCode(code);
    if (!user.email || !isAllowedEmail(user.email)) {
      return NextResponse.redirect(new URL('/login?e=not_allowed', req.url));
    }
    const res = NextResponse.redirect(new URL('/admin/dashboard', req.url));
    await issueSessionCookie(res, { email: user.email, provider: 'kakao' });
    return res;
  } catch {
    return NextResponse.redirect(new URL('/login?e=oauth_fail', req.url));
  }
}

5. JWT session

import { SignJWT, jwtVerify } from 'jose';

const SECRET = new TextEncoder().encode(requireEnv('JWT_SECRET_KEY'));
const EXPIRES = Number(process.env.SESSION_EXPIRES_IN ?? 86400);

export async function issueSessionCookie(res: NextResponse, payload) {
  const jwt = await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(`${EXPIRES}s`)
    .sign(SECRET);
  res.cookies.set('admin_session', jwt, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: EXPIRES,
    path: '/',
  });
}

jose is Edge Runtime compatible.

6. AuthGuard layout

export default async function AdminLayout({ children }) {
  const session = await verifySession();
  if (!session) redirect('/login');
  return <div className="flex"><Sidebar /><main className="flex-1 p-6">{children}</main></div>;
}

7. CSRF state

// initiate
const state = crypto.randomUUID();
res.cookies.set('oauth_state', state, { httpOnly: true, maxAge: 300 });

// callback
const cookieState = req.cookies.get('oauth_state')?.value;
const queryState = req.nextUrl.searchParams.get('state');
if (cookieState !== queryState) return redirect('/login?e=csrf');

Closing

Admin identity is easiest delegated to an external IdP. Allow-list via .env keeps off-boarding as "edit variable, redeploy".

Next

  • 06-audit-log

← Step 4

AdminResourceTable component

Step 6 →

Audit log — logAdminAction