Next.js 연동
이 가이드는 Next.js App Router 애플리케이션에서 tinyauth를 OIDC 제공자로 사용하여 인증을 구현하는 방법을 설명해요. Next.js는 서버 사이드에서 시크릿을 안전하게 보관할 수 있으므로 기밀 클라이언트(Confidential Client) 패턴을 사용해요.
사전 준비
섹션 제목: “사전 준비”1. Tinyauth 클라이언트 등록
섹션 제목: “1. Tinyauth 클라이언트 등록”tinyauth의 config.yaml에 Next.js 앱을 기밀 클라이언트로 등록해요.
clients: - id: nextjs-app name: My Next.js App client_id: nextjs-client-id client_secret: nextjs-client-secret redirect_uris: - http://localhost:3000/api/callback response_types: - code grant_types: - authorization_code - refresh_token scope: openid profile email2. 환경 변수 설정
섹션 제목: “2. 환경 변수 설정”Next.js 프로젝트의 .env.local 파일에 다음 환경 변수를 설정해요.
OIDC_ISSUER=http://localhost:8080OIDC_CLIENT_ID=nextjs-client-idOIDC_CLIENT_SECRET=nextjs-client-secretOIDC_REDIRECT_URI=http://localhost:3000/api/callbackOIDC_SCOPE=openid profile email인증 흐름 개요
섹션 제목: “인증 흐름 개요”Next.js 기밀 클라이언트의 인증 흐름은 다음과 같아요.
- 사용자가 로그인 버튼을 클릭하면
/api/auth/loginAPI 라우트로 이동 - 서버에서 PKCE 쌍, state, nonce를 생성하고 쿠키에 저장
- 사용자를
tinyauth인증 페이지로 리다이렉트 - 인증 완료 후
/api/callback으로 콜백 - 서버에서 인증 코드를 토큰으로 교환 (client_secret 포함)
- 토큰을 httpOnly 쿠키에 안전하게 저장
- 사용자를 프로필 페이지로 리다이렉트
구현 가이드
섹션 제목: “구현 가이드”OIDC Discovery
섹션 제목: “OIDC Discovery”서버 시작 시 tinyauth의 OIDC Discovery 엔드포인트에서 설정을 자동으로 가져와요.
const discoveryUrl = `${process.env.OIDC_ISSUER}/.well-known/openid-configuration`;
export async function getOIDCConfig() { const response = await fetch(discoveryUrl); const discovery = await response.json();
return { authorizationEndpoint: discovery.authorization_endpoint, tokenEndpoint: discovery.token_endpoint, userinfoEndpoint: discovery.userinfo_endpoint, jwksUri: discovery.jwks_uri, clientId: process.env.OIDC_CLIENT_ID, clientSecret: process.env.OIDC_CLIENT_SECRET, redirectUri: process.env.OIDC_REDIRECT_URI, scope: process.env.OIDC_SCOPE, };}로그인 API 라우트
섹션 제목: “로그인 API 라우트”PKCE와 state를 생성하고 인증 URL로 리다이렉트해요.
import { NextResponse } from 'next/server';import { getOIDCConfig } from '@/lib/oidc-config';import { generatePKCE } from '@/lib/pkce';
export async function GET() { const config = await getOIDCConfig(); const { codeVerifier, codeChallenge } = await generatePKCE(); const state = crypto.randomUUID(); const nonce = crypto.randomUUID();
// state와 code_verifier를 쿠키에 저장 const authState = JSON.stringify({ state, codeVerifier, nonce });
const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: config.redirectUri, response_type: 'code', scope: config.scope, state, nonce, code_challenge: codeChallenge, code_challenge_method: 'S256', });
const response = NextResponse.redirect( `${config.authorizationEndpoint}?${params}` );
response.cookies.set('oidc_state', authState, { httpOnly: true, sameSite: 'lax', maxAge: 600, // 10분 });
return response;}PKCE 헬퍼
섹션 제목: “PKCE 헬퍼”export async function generatePKCE() { const array = new Uint8Array(32); crypto.getRandomValues(array); const codeVerifier = base64url(array);
const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier) ); const codeChallenge = base64url(new Uint8Array(digest));
return { codeVerifier, codeChallenge };}
function base64url(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '');}콜백 API 라우트
섹션 제목: “콜백 API 라우트”인증 코드를 토큰으로 교환하고 안전하게 저장해요.
import { NextRequest, NextResponse } from 'next/server';import { getOIDCConfig } from '@/lib/oidc-config';
export async function GET(req: NextRequest) { const config = await getOIDCConfig(); const code = req.nextUrl.searchParams.get('code'); const state = req.nextUrl.searchParams.get('state');
// state 검증 const authStateCookie = req.cookies.get('oidc_state')?.value; const authState = JSON.parse(authStateCookie || '{}');
if (state !== authState.state) { return NextResponse.redirect(new URL('/error', req.url)); }
// 토큰 교환 (client_secret 포함) const tokenResponse = await fetch(config.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: config.redirectUri, client_id: config.clientId, client_secret: config.clientSecret, // 기밀 클라이언트 code_verifier: authState.codeVerifier, }), });
const tokens = await tokenResponse.json();
// 토큰을 httpOnly 쿠키에 저장 const response = NextResponse.redirect(new URL('/profile', req.url)); response.cookies.delete('oidc_state'); response.cookies.set('oidc_tokens', JSON.stringify(tokens), { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24 * 30, // 30일 });
return response;}로그아웃 API 라우트
섹션 제목: “로그아웃 API 라우트”import { NextResponse } from 'next/server';
export async function GET() { const response = NextResponse.redirect(new URL('/', process.env.NEXT_PUBLIC_BASE_URL)); response.cookies.delete('oidc_tokens'); return response;}핵심 포인트
섹션 제목: “핵심 포인트”- 토큰은 httpOnly 쿠키에 저장해요. 클라이언트 JavaScript에서 접근할 수 없어 XSS 공격으로부터 안전해요.
- 토큰 교환은 서버 사이드에서 이루어져요.
client_secret이 브라우저에 노출되지 않아요. - PKCE를 사용해요. 기밀 클라이언트라도 PKCE를 함께 사용하면 보안이 더 강화돼요.
- **토큰 새로고침(refresh)**은 서버 API 라우트를 통해 프록시하는 것이 좋아요.