
# Next.js SDK

`@keenauth/nextjs`는 Next.js App Router 애플리케이션에 로그인 기능을 추가합니다. 애플리케이션이 OAuth 클라이언트이며 모든 토큰을 서버에 보관합니다. 브라우저는 암호화되고 HttpOnly 속성이 설정된 세션 쿠키만 받으며 액세스 토큰, 리프레시 토큰, ID 토큰은 전혀 볼 수 없습니다. 이는 백엔드 포 프런트엔드(BFF) 패턴입니다. 이 패키지에는 런타임 의존성이 없습니다. 아직 npm에 게시되지 않았습니다.

## 시작하기 전에

1. 대시보드에서 환경에 웹 애플리케이션을 만드십시오.
2. `private_key_jwt` 클라이언트 인증을 선택하고 공개 키를 등록하십시오. 클라이언트 시크릿도 사용할 수 있습니다.
3. 리디렉션 URI `https://app.acme.example/auth/callback`과 로그아웃 후 리디렉션 URI `https://app.acme.example/`를 추가하십시오.
4. 최소 32자 이상의 쿠키 시크릿을 생성하십시오. 예를 들어 `openssl rand -base64 32`를 사용할 수 있습니다. 저장소가 아니라 시크릿 저장소에 보관하십시오.

## 인스턴스 생성

```ts
// lib/auth.ts
import { createKeenAuth } from "@keenauth/nextjs";

export const auth = createKeenAuth({
  issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "private_key_jwt", privateKey: JSON.parse(process.env.KEENAUTH_CLIENT_KEY!) },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});
```

`appBaseUrl`은 애플리케이션의 공개 주소입니다. SDK는 리디렉션 URI와 모든 리디렉션을 이 값으로 만들며, 요청의 `Host`나 `X-Forwarded-*` 헤더에서는 만들지 않습니다. 일반 `http`는 `localhost`에서만 허용됩니다.

옵션은 인스턴스를 만들 때 검사되므로, 설정 오류가 있으면 시작 단계에서 애플리케이션이 중지됩니다.

## 라우트 추가

```ts
// app/auth/[...keenauth]/route.ts
import { auth } from "@/lib/auth";

export const { GET, POST } = auth.handlers;
```

이 설정으로 세 개의 라우트가 제공됩니다.

| 라우트 | 기능 |
|---|---|
| `/auth/login` | 로그인을 시작합니다. 선택적 `returnTo` 쿼리 매개변수로 로그인 후 사용자가 이동할 위치를 지정합니다. |
| `/auth/callback` | 로그인을 완료하고 세션 쿠키를 설정합니다. |
| `/auth/logout` | KeenAuth에서 로그아웃하고 애플리케이션으로 돌아갑니다. |

로그인에는 PKCE를 사용하는 인가 코드 흐름이 사용됩니다. 환경에서 지원하는 경우 요청은 서버로 푸시됩니다(PAR). 콜백에서 SDK는 `state`, `iss`, ID 토큰(`nonce` 포함)을 검사합니다.

`returnTo`는 `/orders?tab=open`과 같이 애플리케이션 자체의 경로여야 합니다. `//evil.example`이나 전체 URL과 같은 다른 값은 무시되며 사용자는 `/`로 이동합니다.

## 페이지 보호 및 토큰 갱신

```ts
// proxy.ts (Next.js 16 이전에는 middleware.ts)
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
  const result = await auth.proxy(request, {
    protect: (path) => path.startsWith("/orders") || path.startsWith("/api/"),
  });
  if (result.response) return result.response;
  const response = NextResponse.next({ request: { headers: result.requestHeaders } });
  result.apply(response);
  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

세션이 없는 보호된 경로에 대해서는 다음과 같이 처리됩니다.

- 페이지 요청(`GET` 또는 `HEAD`)은 `returnTo`와 함께 `/auth/login`으로 리디렉션됩니다.
- `/api/…`에 대한 요청이나 다른 메서드의 요청은 문제 문서(problem document)와 함께 `401`을 받습니다.

액세스 토큰이 60초 이내에 만료되면 프록시가 토큰을 갱신합니다. 갱신할 때마다 리프레시 토큰이 교체되며, 새 세션 쿠키가 응답에 설정됩니다. `requestHeaders`는 같은 요청을 렌더링하는 페이지에 새 세션을 전달합니다.

서버가 리프레시 토큰을 거부하면 세션이 종료됩니다. 일시적인 서버 오류가 발생하면 세션은 변경되지 않고 유지됩니다.

각 서버 함수(Server Function)와 라우트 핸들러 내부에서도 세션을 확인하십시오. 프록시 matcher가 일부 경로를 놓칠 수 있습니다.

## 세션 읽기

서버 컴포넌트에서는 쿠키 저장소를 전달하십시오.

```tsx
// app/orders/page.tsx
import { cookies } from "next/headers";
import { auth } from "@/lib/auth";

export default async function Orders() {
  const session = await auth.getSession(await cookies());
  if (!session) return <a href="/auth/login?returnTo=/orders">Sign in</a>;
  return <p>Signed in as {session.user.email}</p>;
}
```

`session.user`에는 ID 토큰의 타입이 지정된 클레임인 `subject`, `email`, `emailVerified`, `name`, `orgId`, `orgRoles` 등이 들어 있습니다. 사용자 정의 클레임을 포함한 모든 클레임은 `session.user.claims`에 있습니다. 세션 객체에는 토큰이 포함되지 않습니다.

서버 컴포넌트에서는 쿠키를 설정할 수 없으므로, 그곳에서 세션을 읽어도 세션이 갱신되지는 않습니다.

## API 호출

서버 컴포넌트에서는 쿠키 저장소에서 액세스 토큰을 가져오십시오.

```ts
const token = await auth.getAccessToken(await cookies());
const res = await fetch("https://api.acme.example/v1/orders", {
  headers: { Authorization: `Bearer ${token}` },
});
```

`getAccessToken`은 다음 경우에 실패합니다.

- 토큰이 만료에 가까워 프록시가 갱신했어야 하는 경우 `refresh_required`;
- 세션이 없는 경우 `login_required`.

라우트 핸들러에서는 `updateSession`으로도 갱신할 수 있습니다.

```ts
// app/api/orders/route.ts
import { auth } from "@/lib/auth";

export async function GET(request: Request) {
  const { accessToken, apply } = await auth.updateSession(request);
  if (!accessToken) return new Response(null, { status: 401 });
  const upstream = await fetch("https://api.acme.example/v1/orders", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  const response = new Response(upstream.body, { status: upstream.status });
  apply(response);
  return response;
}
```

`auth.authorizedFetch`는 한 번의 호출로 같은 작업을 수행합니다. `getAccessToken`과 같이 토큰을 읽고, `Authorization` 헤더를 추가하며, DPoP를 사용하는 경우 증명(proof)도 추가합니다.

```ts
const res = await auth.authorizedFetch(await cookies(), "https://api.acme.example/v1/orders");
```

액세스 토큰을 클라이언트 컴포넌트에 전달하지 마십시오. API는 서버에서 호출하고, 브라우저에는 데이터만 보내십시오. API에서 이러한 토큰을 검증하는 방법은 [API에서 토큰 검증하기](/docs/sdk/token-verification)를 참조하십시오.

## 로그아웃

`/auth/logout`으로 링크를 연결하거나, 폼에서 이 경로로 `POST` 요청을 보내십시오. SDK는 다음 작업을 수행합니다.

1. 세션 쿠키를 삭제합니다.
2. 리프레시 토큰을 취소합니다.
3. 브라우저를 환경의 로그아웃 엔드포인트로 보내고, 이 엔드포인트가 브라우저를 애플리케이션으로 돌려보냅니다.

브라우저가 다른 사이트에서 온 요청으로 표시한 요청은 거부되므로, 다른 사이트가 사용자를 로그아웃시킬 수 없습니다. `Sec-Fetch-Site`를 보내지 않는 브라우저에서는 애플리케이션 자체 페이지에서 보내는 `POST`로만 로그아웃할 수 있습니다. `/auth/logout`으로 POST하는 폼을 사용하는 것이 좋습니다.

## 송신자 제약 토큰(DPoP)

DPoP(RFC 9449)를 사용하면 액세스 토큰이 서버만 보유한 키에 바인딩됩니다. API 로그 등에서 토큰이 유출되더라도 해당 키 없이는 토큰을 사용할 수 없습니다.

1. 애플리케이션용 ES256(P-256) 키를 하나 생성하고, 개인 JWK를 시크릿 저장소에 보관하십시오.
2. 이를 `dpop`으로 전달하십시오.

```ts
export const auth = createKeenAuth({
  // ...the options above
  dpop: { privateKey: JSON.parse(process.env.KEENAUTH_DPOP_KEY!) },
});
```

그러면 SDK는 다음을 수행합니다.

- 인가 코드를 키에 바인딩합니다(`dpop_jkt`, 푸시된 인가 요청과 함께 전송).
- 모든 토큰 및 리프레시 요청에 DPoP 증명을 보내고, 서버가 새 nonce를 요구하면 한 번 재시도합니다.
- `token_type`이 `DPoP`가 아닌 토큰 응답을 거부합니다.

API를 호출할 때는 `auth.authorizedFetch`를 사용하십시오. 이 함수는 요청마다 `Authorization: DPoP <token>`과 `ath`가 포함된 새 증명을 보내며, API가 `error="use_dpop_nonce"`와 함께 `401`을 응답하면 한 번 재시도합니다. 자체 HTTP 클라이언트를 사용하는 경우에는 `auth.createDpopProof({ method, url, accessToken, nonce })`로 증명을 만드십시오.

모든 서버 프로세스에서 같은 키를 사용하십시오. 환경이 토큰과 리프레시 토큰을 이 키에 바인딩하기 때문입니다. 키를 교체하거나 DPoP를 켜거나 끄면 기존 세션이 종료되고 사용자는 다시 로그인해야 합니다.

## 서버 측 세션

기본적으로 세션 전체가 쿠키에 봉인됩니다. 세션을 서버에 보관하려면 `sessionStore`를 전달하십시오. 그러면 쿠키에는 무작위 세션 ID에 대한 암호화된 참조만 저장됩니다.

```ts
import { createKeenAuth, memorySessionStore, type SessionStore } from "@keenauth/nextjs";

// Next.js bundles proxy.ts, route handlers and pages separately, and each bundle runs its own copy of this module.
// Keep the store on globalThis so that they all share one store in the server process.
const shared = globalThis as { keenauthSessions?: SessionStore };
const sessionStore = (shared.keenauthSessions ??= memorySessionStore());

export const auth = createKeenAuth({
  // ...the options above
  sessionStore,
});
```

위와 같이 인메모리 저장소는 프로세스당 한 번만 만드십시오. `globalThis`를 사용하지 않고 모듈 최상위 수준에서 `memorySessionStore()`를 호출하면, 프록시, 각 라우트 핸들러, 페이지가 각자 빈 저장소를 갖게 됩니다. 그러면 콜백 라우트가 저장한 세션을 프록시나 페이지가 찾지 못해 사용자가 로그아웃된 것처럼 보입니다. 데이터를 프로세스 외부에 보관하는 저장소(Redis, 데이터베이스)에서는 이 문제가 발생하지 않습니다.

`memorySessionStore`는 세션을 하나의 프로세스 메모리에 보관하며, 재시작하면 세션이 사라집니다. 개발 환경이나 인스턴스가 하나인 서버에서 사용하십시오. 인스턴스가 여러 개이거나 서버리스 함수를 사용하는 경우에는 Redis나 데이터베이스 같은 공유 저장소에 `SessionStore` 인터페이스를 구현하십시오.

```ts
interface SessionStore {
  get(id: string): Promise<SessionRecord | null>;
  set(id: string, record: SessionRecord): Promise<void>;
  delete(id: string): Promise<void>;
  deleteByLogout(target: { sub?: string; sid?: string }): Promise<void>;
}
// SessionRecord: { value: string; sub: string; sid?: string; expiresAt: number }
```

- `value`는 여전히 `secret`으로 암호화되므로, 저장소를 복사해도 토큰이 노출되지 않습니다. 로그에 기록하지 마십시오.
- `sid`와 함께 `deleteByLogout`을 호출하면 해당 `sid`를 가진 세션이 삭제됩니다. `sub`도 함께 지정하면 두 값이 모두 일치해야 합니다. `sub`만 지정하면 해당 사용자의 모든 세션이 삭제됩니다.
- `expiresAt` 이후에는 레코드를 삭제하십시오.
- 패키지 저장소에는 저장소가 통과해야 하는 계약 테스트(`clients/nextjs/test/store-contract.ts`)가 있습니다.

저장소에 장애가 발생하면 SDK는 사용자를 로그아웃 상태로 취급하지 않고 `session_store_error`를 발생시킵니다. 로그아웃은 여전히 쿠키를 삭제합니다.

## 백채널 로그아웃

`sessionStore`를 사용하면 SDK는 `/auth/backchannel-logout`도 제공합니다. KeenAuth에서 세션이 종료되면(예: 사용자가 다른 애플리케이션에서 로그아웃하거나 관리자가 세션을 취소한 경우), 환경은 서명된 로그아웃 토큰을 이 엔드포인트로 보냅니다. SDK는 일치하는 세션을 삭제하므로, 사용자의 다음 요청에는 세션이 없습니다.

1. `https://app.acme.example/auth/backchannel-logout`을 애플리케이션의 백채널 로그아웃 URI로 등록하십시오.
2. 이 라우트에 인터넷에서 접근할 수 있는지 확인하십시오. 프록시 matcher에 이 경로가 포함될 수 있지만, 프록시는 이 경로를 통과시킵니다.

SDK는 OpenID Connect Back-Channel Logout 1.0이 요구하는 대로 로그아웃 토큰을 검사합니다. 검사 항목은 서명, 발급자, 대상(audience), `iat`와 `exp`, 로그아웃 이벤트, `sub` 또는 `sid`, `nonce`가 없을 것, `logout+jwt` 타입입니다. 성공하면 `200`을, 그 밖의 경우에는 `400`을 응답하며, 환경은 실패한 전송을 다시 시도합니다.

각 로그아웃 토큰의 `jti`는 토큰이 만료될 때까지 기억되므로, 탈취된 토큰을 재사용할 수 없습니다. 이미 처리된 `jti`는 다시 작업을 수행하지 않고 `200`으로 응답합니다. 기본 캐시는 프로세스 하나에만 존재합니다. 인스턴스가 여러 개인 경우에는 `remember(jti, expiresAt)`(원자적으로 동작해야 하며, 예: Redis `SET NX`)와 `forget(jti)`를 구현한 공유 `logoutReplayCache`를 전달하십시오.

## 세션 쿠키

- 쿠키는 `secret`에서 파생된 키를 사용하여 AES-256-GCM으로 암호화되고 인증됩니다. 변경되거나 복사된 쿠키는 거부됩니다.
- 쿠키 이름은 `__Host-keenauth_session.0`이며, `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/` 속성으로 설정됩니다. `http://localhost`에서는 `__Host-` 접두사와 `Secure`를 사용하지 않습니다.
- 세션은 최대 7일(`sessionMaxAge`) 동안 유지되며, 갱신해도 연장되지 않습니다.
- 세션 데이터가 큰 경우 최대 세 개의 쿠키로 나뉩니다.

시크릿을 변경하려면 목록을 전달하십시오. 예: `secret: [newSecret, oldSecret]`. 첫 번째 시크릿으로 암호화하고, 모든 시크릿으로 복호화합니다. 이전 시크릿은 `sessionMaxAge` 이후에 제거하십시오.

## 현재 제한 사항

- 이 패키지에서는 아직 임베디드 컴포넌트를 사용한 로그인을 지원하지 않습니다.
- 토큰 갱신은 하나의 서버 프로세스 안에서만 공유됩니다. 인스턴스가 여러 개이면 두 인스턴스가 같은 세션을 동시에 갱신할 수 있습니다. 애플리케이션 설정 `refresh_reuse_interval_s`(기본값 5초, [애플리케이션 업데이트](/docs/api/management/applications/update) 참조)를 사용하면 두 번째 갱신이 성공합니다. 이 값을 `0`으로 설정하면 두 번째 갱신이 세션을 종료합니다.
- `sessionStore`가 없으면 백채널 로그아웃을 사용할 수 없습니다. 이 경우 리프레시 토큰이 거부되면 서버에서 세션이 종료됩니다.
