Tinyauth

Next.js 연동

Next.js 애플리케이션과 Tinyauth 연동하기

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


사전 준비

1. Tinyauth 클라이언트 등록

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

2. 환경 변수 설정

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

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. 사용자를 프로필 페이지로 리다이렉트

구현 가이드

OIDC Discovery

서버 시작 시 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,
  };
}

로그인 API 라우트

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;
}

PKCE 헬퍼

// 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(/=+$/, '');
}

콜백 API 라우트

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

// 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;
}

로그아웃 API 라우트

// 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 라우트를 통해 프록시하는 것이 좋아요.
Note

더 완전한 구현 예시는 프로젝트의 examples/clients/nextjs-ssr 디렉터리를 참고해 주세요. 토큰 인트로스펙션, 토큰 취소, 에러 처리 등의 추가 기능을 포함하고 있어요.