Step 5
OAuth 2 providers + allow-list
30 min
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