
# Next.js 앱에 로그인 추가

이 퀵스타트는 `@keenauth/nextjs` SDK로 Next.js App Router 앱에 KeenAuth 로그인을 추가합니다. 앱은 OAuth 클라이언트이며 모든 토큰을 서버에 보관합니다. 브라우저는 암호화된 HttpOnly 세션 쿠키만 받습니다. 로그인 경로를 추가하고, 페이지를 보호하고, 사용자의 액세스 토큰으로 보호된 API를 호출한 뒤, 앱을 오프라인에서 테스트합니다. SDK가 지원하는 라우터는 App Router뿐입니다. SDK에 대한 설명은 [Next.js SDK](/docs/sdk/nextjs)를 참조하십시오.

앱에는 다음 페이지와 경로가 있습니다.

| 경로 | 역할 |
|---|---|
| `/` | 공개 홈 페이지: 로그인 링크, 또는 사용자 이름과 로그아웃 버튼 |
| `/dashboard` | 세션이 필요합니다. 사용자와 API가 반환하는 메시지를 표시합니다 |
| `/api/messages` | 세션이 필요합니다. 사용자의 액세스 토큰으로 API를 호출합니다 |
| `/auth/login`, `/auth/callback`, `/auth/logout` | SDK가 제공합니다 |

## 시작하기 전에

- KeenAuth 환경. 발급자(issuer)는 `https://your-environment.eu.keenauth.app` 형식입니다.
- 환경에 등록된 웹 애플리케이션([애플리케이션 생성](/docs/api/management/applications/create)). 클라이언트 인증으로 `private_key_jwt` 또는 클라이언트 시크릿을 사용합니다. `development` 환경에서는 리디렉션 URI `http://localhost/auth/callback`과 로그아웃 후 리디렉션 URI `http://localhost/`를 등록하십시오. 루프백 URI는 포트 없이 등록되며 `3000`과 같은 모든 포트와 일치합니다.
- 환경에 등록된 API([API 생성](/docs/api/management/apis/create)). `messages:read` 권한(scope)이 있어야 합니다. 예를 들어 `https://api.example.com`인 `identifier`가 액세스 토큰의 대상(audience)입니다. [Express 퀵스타트](/docs/quickstarts/express)에서 이 토큰을 받는 API를 만들 수 있습니다.
- Node.js 22.18 이상.

## 1. 설치

```sh
npm install @keenauth/nextjs next@16.4.2 react@19.3.1 react-dom@19.3.1
```

SDK는 아직 공개되지 않았습니다. 공개될 때까지 예제는 `package.json`에 있는 SDK 소스 링크를 사용합니다.

## 2. 구성

앱은 환경 변수에서만 설정을 읽습니다. Next.js가 읽는 `.env.local`에 넣고, 이 파일은 절대 커밋하지 마십시오.

| 변수 | 필수 여부 | 의미 |
|---|---|---|
| `KEENAUTH_ISSUER` | 예 | 환경의 발급자 |
| `KEENAUTH_CLIENT_ID` | 둘 중 하나 | 웹 애플리케이션의 `client_id` |
| `KEENAUTH_CLIENT_KEY` | 둘 중 하나 | `private_key_jwt`용 개인 키(JSON 개인 JWK), 권장 |
| `KEENAUTH_CLIENT_SECRET` | 둘 중 하나 | 클라이언트 시크릿 |
| `APP_BASE_URL` | 예 | 앱의 공개 URL. SDK는 이 값으로 리디렉션 URI와 모든 리디렉션을 만듭니다. `localhost`, `127.0.0.1` 또는 `[::1]`에서만 `http`를 사용하십시오 |
| `KEENAUTH_COOKIE_SECRET` | 예 | 최소 32자. 세션 쿠키를 봉인합니다. `openssl rand -base64 32`로 생성하십시오 |
| `API_URL` | 예 | 앱이 호출하는 API. 예: `http://localhost:8080` |
| `KEENAUTH_API_AUDIENCE` | 예 | KeenAuth에 등록된 그 API의 식별자 |

`loadConfig`는 이 값을 읽고 검사합니다. 값이 없거나, 클라이언트 인증 정보가 둘 다 설정되었거나 둘 다 없으면 실행을 중단하며, 오류 메시지에는 시크릿이 포함되지 않습니다.

```ts
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
  const need = (name: string): string => {
    const value = env[name];
    if (!value) throw new Error(`${name} is required`);
    return value;
  };
```

## 3. SDK 인스턴스 생성

`createAuth`는 SDK 인스턴스를 만들며, SDK는 생성 시 모든 옵션을 검사합니다. `getApp`은 처음 사용될 때 프로세스당 인스턴스를 하나 만들므로 `next build`에는 시크릿이 필요하지 않습니다.

```ts
/** Builds the SDK instance. The options are checked here, so a configuration error stops the app at start-up. */
export function createAuth(config: AppConfig, overrides: Partial<KeenAuthOptions> = {}): KeenAuth {
  return createKeenAuth({ ...config.auth, ...overrides });
}

let instance: { auth: KeenAuth; config: AppConfig } | undefined;

/** One instance per process, created on first use so that `next build` needs no secrets. */
export function getApp(): { auth: KeenAuth; config: AppConfig } {
  if (!instance) {
    const config = loadConfig(process.env);
    instance = { auth: createAuth(config), config };
  }
  return instance;
}
```

구성은 `resource`를 API의 식별자로 설정합니다(RFC 8707). 이 설정이 없으면 액세스 토큰은 여러분의 API를 대상으로 하지 않으므로 API가 거부합니다.

## 4. 로그인 경로 추가

하나의 catch-all 라우트 핸들러가 `/auth/login`, `/auth/callback`, `/auth/logout`을 처리합니다.

```ts
// Serves /auth/login, /auth/callback and /auth/logout.
export const GET = (request: Request) => getAuth().handlers.GET(request);
export const POST = (request: Request) => getAuth().handlers.POST(request);
```

로그인에는 PKCE를 사용하는 인가 코드 흐름이 쓰이며, 환경이 지원하면 푸시된 인가 요청(pushed authorization request)을 사용합니다. 콜백은 `state`, `iss`, ID 토큰을 검사합니다. 앱의 경로가 아닌 `returnTo`는 무시됩니다.

## 5. 프록시로 페이지 보호

`proxy.ts`(Next.js 16 이전에는 `middleware.ts`)는 페이지가 렌더링되기 전에 세션을 갱신하고, 선택한 경로를 보호합니다. 세션 없이 페이지를 요청하면 `returnTo`와 함께 `/auth/login`으로 리디렉션되며, `/api/…` 요청은 `401`을 받습니다.

```ts
export async function proxy(request: NextRequest) {
  const result = await getAuth().proxy(request, { protect: isProtected });
  if (result.response) return result.response;
  const response = NextResponse.next({ request: { headers: result.requestHeaders } });
  result.apply(response);
  return response;
}
```

세션이 필요한 경로는 하나의 함수에 정의합니다.

```ts
export function isProtected(pathname: string): boolean {
  return pathname === "/dashboard" || pathname.startsWith("/dashboard/") || pathname.startsWith("/api/");
}
```

프록시 매처가 경로를 놓칠 수 있으므로, 사용자가 필요한 모든 페이지와 라우트 핸들러도 세션을 직접 확인합니다.

## 6. API 호출

Server Component에서 세션을 읽고 쿠키 저장소에서 액세스 토큰을 가져옵니다. 토큰은 서버에 머물고, 브라우저는 HTML만 받습니다.

```tsx
  // The proxy has already sent users without a session to the sign-in; check again here, because a matcher can miss a path.
  const session = await auth.getSession(cookieStore);
  if (!session) return <a href="/auth/login?returnTo=/dashboard">Sign in</a>;

  // The access token stays on the server. The browser receives only the HTML below.
  const response = await fetchMessages(config.apiUrl, await auth.getAccessToken(cookieStore));
```

`session.user`에는 ID 토큰의 클레임, 예를 들어 `subject`, `email`, `name`이 들어 있습니다. 세션에는 토큰이 전혀 포함되지 않습니다.

실제 호출은 Bearer 토큰을 넣은 일반 `fetch`입니다.

```ts
export async function fetchMessages(apiUrl: string, accessToken: string, fetchImpl: typeof fetch = fetch): Promise<Response> {
  return fetchImpl(new URL("/api/messages", apiUrl), {
    headers: { Authorization: `Bearer ${accessToken}` },
    cache: "no-store",
  });
}
```

라우트 핸들러는 쿠키를 설정할 수 있으므로 만료가 임박한 액세스 토큰을 갱신하는 `updateSession`을 사용합니다.

```ts
  // A route handler can set cookies, so updateSession also refreshes an access token that is about to expire.
  const { accessToken, apply } = await auth.updateSession(request);
  if (!accessToken) return new Response(null, { status: 401 });
```

액세스 토큰을 Client Component에 전달하지 마십시오.

## 7. 로그아웃

로그아웃은 `/auth/logout`으로 POST하는 폼입니다. SDK는 세션 쿠키를 삭제하고, 리프레시 토큰을 취소하며, 브라우저를 환경의 로그아웃 엔드포인트로 보냅니다. 이 엔드포인트가 브라우저를 앱으로 되돌립니다. 브라우저가 다른 사이트에서 왔다고 표시한 요청은 거부됩니다.

```tsx
      <form action="/auth/logout" method="post">
        <button type="submit">Sign out</button>
      </form>
```

## 8. 앱 실행

Express 퀵스타트의 API(또는 이 토큰을 받는 다른 API)를 시작한 뒤 앱을 실행하십시오.

```sh
npm run dev
```

`http://localhost:3000`을 열고 **Sign in**을 선택한 뒤 KeenAuth에서 로그인하고 대시보드를 여십시오.

## 9. 네트워크 없이 테스트

테스트에는 네트워크나 KeenAuth 계정이 필요하지 않습니다. 가짜 `fetch`가 인가 서버 역할(디스커버리와 푸시된 인가 요청)을 하며, Node가 TypeScript 테스트를 바로 실행합니다.

```ts
test("proxy: a page request without a session is redirected to sign-in with returnTo", async () => {
  const { auth } = app();
  const result = await auth.proxy(new Request(`${APP}/dashboard?tab=1`), { protect: isProtected });
  assert.equal(result.response?.status, 307);
```

```sh
npm test
npm run deps    # pinned versions, lockfile sources and licences
```

테스트는 다음을 확인합니다. 구성이 시작 시 검증되는지, 세션이 없는 페이지는 로그인으로 리디렉션되고 API 경로는 `401`을 응답하는지, 로그인이 PKCE S256 푸시 요청을 보내고 봉인된 HttpOnly 쿠키를 설정하는지, 알 수 없는 state의 콜백이 거부되는지, 다른 사이트에서 온 로로그아웃이 거부되는지, 액세스 토큰이 `Authorization` 헤더로 API에 전달되는지.

실제 브라우저로 하는 전체 로그인에는 KeenAuth 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.

## 프록시 또는 로드 밸런서 뒤에서

`APP_BASE_URL`을 앱의 공개 URL로 설정하십시오. SDK는 리디렉션을 만들 때 `Host`나 `X-Forwarded-*`를 읽지 않습니다. 서버 인스턴스가 여러 개이면 두 인스턴스가 동시에 리프레시 토큰을 갱신할 수 있습니다. 자세한 내용은 [현재 제한 사항](/docs/sdk/nextjs#현재-제한-사항)을 참조하십시오.

## 다음 단계

- [Next.js SDK](/docs/sdk/nextjs): 모든 옵션, 세션 쿠키, 현재 제한 사항.
- [Express API 보호하기](/docs/quickstarts/express): 이 앱이 호출하는 API.
- [토큰 검증](/docs/sdk/token-verification): API 미들웨어 아래에서 동작하는 검증기.
