Skip to content

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/callback

2. 로그인 시작 라우트

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 검증.

MIT License · Identity가 제품의 신뢰를 만듭니다.