Next.js (App Router)
로그인 버튼 → Authorization Code + PKCE 풀 플로우.
1. 환경변수
bash
# .env.local
LOGI_API_URL=https://logi.example.com
LOGI_CLIENT_ID=logi_...
LOGI_CLIENT_SECRET=logi_secret_...
LOGI_REDIRECT_URI=http://localhost:3000/api/auth/callback2. 로그인 시작 라우트
ts
// app/api/auth/login/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
function base64url(buf: ArrayBuffer) {
return Buffer.from(buf).toString("base64url");
}
export async function GET() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const jar = await cookies();
jar.set("logi_pkce", verifier, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 });
jar.set("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 });
const url = new URL(`${process.env.LOGI_API_URL}/oauth/authorize`);
url.searchParams.set("client_id", process.env.LOGI_CLIENT_ID!);
url.searchParams.set("redirect_uri", process.env.LOGI_REDIRECT_URI!);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "profile email");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return NextResponse.redirect(url);
}3. 콜백 라우트
ts
// app/api/auth/callback/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const u = new URL(req.url);
const code = u.searchParams.get("code");
const state = u.searchParams.get("state");
const jar = await cookies();
if (!code || !state || state !== jar.get("logi_state")?.value) {
return NextResponse.json({ error: "state mismatch" }, { status: 400 });
}
const verifier = jar.get("logi_pkce")?.value!;
const body = new URLSearchParams({
grant_type: "authorization_code",
code, redirect_uri: process.env.LOGI_REDIRECT_URI!,
code_verifier: verifier,
client_id: process.env.LOGI_CLIENT_ID!,
client_secret: process.env.LOGI_CLIENT_SECRET!,
});
const res = await fetch(`${process.env.LOGI_API_URL}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const tokens = await res.json();
// tokens.refresh_token → httpOnly cookie (rotation 자동 커버)
jar.set("logi_rt", tokens.refresh_token, { httpOnly: true, secure: true, sameSite: "strict" });
jar.delete("logi_pkce");
jar.delete("logi_state");
return NextResponse.redirect(new URL("/", req.url));
}4. 보호된 API에서 userinfo 조회
ts
const me = await fetch(`${process.env.LOGI_API_URL}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json());JWT 검증(stateless)을 선호하면 jose로 JWKS 검증.