콘텐츠로 이동

Next.js 연동

이 가이드는 Next.js App Router 애플리케이션에서 tinyauth를 OIDC 제공자로 사용하여 인증을 구현하는 방법을 설명해요. Next.js는 서버 사이드에서 시크릿을 안전하게 보관할 수 있으므로 기밀 클라이언트(Confidential Client) 패턴을 사용해요.


tinyauthconfig.yaml에 Next.js 앱을 기밀 클라이언트로 등록해요.

config.yaml
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 email

Next.js 프로젝트의 .env.local 파일에 다음 환경 변수를 설정해요.

Terminal window
OIDC_ISSUER=http://localhost:8080
OIDC_CLIENT_ID=nextjs-client-id
OIDC_CLIENT_SECRET=nextjs-client-secret
OIDC_REDIRECT_URI=http://localhost:3000/api/callback
OIDC_SCOPE=openid profile email

Next.js 기밀 클라이언트의 인증 흐름은 다음과 같아요.

  1. 사용자가 로그인 버튼을 클릭하면 /api/auth/login API 라우트로 이동
  2. 서버에서 PKCE 쌍, state, nonce를 생성하고 쿠키에 저장
  3. 사용자를 tinyauth 인증 페이지로 리다이렉트
  4. 인증 완료 후 /api/callback으로 콜백
  5. 서버에서 인증 코드를 토큰으로 교환 (client_secret 포함)
  6. 토큰을 httpOnly 쿠키에 안전하게 저장
  7. 사용자를 프로필 페이지로 리다이렉트

서버 시작 시 tinyauth의 OIDC Discovery 엔드포인트에서 설정을 자동으로 가져와요.

lib/oidc-config.ts
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,
};
}

PKCE와 state를 생성하고 인증 URL로 리다이렉트해요.

app/api/auth/login/route.ts
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;
}
lib/pkce.ts
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(/=+$/, '');
}

인증 코드를 토큰으로 교환하고 안전하게 저장해요.

app/api/callback/route.ts
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;
}
app/api/auth/logout/route.ts
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 라우트를 통해 프록시하는 것이 좋아요.