
# Fastify 세션

`@keenauth/node/bff`는 Fastify 5 애플리케이션에 로그인 기능을 추가합니다. 서버가 OAuth 클라이언트 역할을 하며 모든 토큰을 서버에 보관합니다. 브라우저에는 암호화된 HttpOnly 세션 쿠키만 전달되며, 액세스 토큰, 리프레시 토큰, ID 토큰은 브라우저에 노출되지 않습니다. 이는 BFF(backend-for-frontend) 패턴입니다. 이 패키지는 Fastify를 import하지 않으므로 추가 의존성이 생기지 않습니다. 아직 npm에 게시되지는 않았습니다.

대신 베어러 토큰을 받는 API를 보호하려면 [API 보호](/docs/sdk/protect-api)를 참조하세요.

## 시작하기 전에

1. 대시보드에서 환경에 웹 애플리케이션을 만드세요.
2. 클라이언트 인증 방식을 선택하세요. 클라이언트 비밀 값(`client_secret_basic`)을 사용하거나, 공개 키를 애플리케이션에 등록한 키 쌍으로 `private_key_jwt`를 사용할 수 있습니다. 공유 비밀 값이 서버 밖으로 나가지 않으므로 `private_key_jwt`를 권장합니다.
3. 리디렉션 URI `https://app.acme.example/auth/callback`과 로그아웃 후 리디렉션 URI `https://app.acme.example/`를 추가하세요.
4. 32자 이상의 쿠키 비밀 값을 생성하세요. 예를 들어 `openssl rand -base64 32`를 사용할 수 있습니다. 저장소가 아닌 비밀 값 저장소에 보관하세요.

## 설정

```ts
import Fastify from "fastify";
import { createBff, fastifyRequireAuth, fastifySession } from "@keenauth/node/bff";

const bff = createBff({
  issuer: "https://acme.eu.keenauth.app", // 환경의 issuer를 정확히 입력하세요
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "client_secret_basic", clientSecret: process.env.KEENAUTH_CLIENT_SECRET! },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});

const app = Fastify();
fastifySession(app, bff); // 모든 라우트에 적용되도록 루트 인스턴스에서 호출하세요

app.get("/orders", { preHandler: fastifyRequireAuth(bff) }, async (request) => {
  return `Signed in as ${request.keenauth!.session!.user.email}`;
});
```

`private_key_jwt`를 사용하는 경우 비밀 값 대신 비공개 키를 전달하세요. 예: `clientAuth: { method: "private_key_jwt", privateKey: readFileSync("bff-key.pem", "utf8") }`. SDK는 토큰 및 PAR 엔드포인트로 보내는 요청마다 새 클라이언트 어설션을 서명합니다. 키가 KMS나 HSM에 있다면 `clientAuth: { method: "private_key_jwt", assertion }`을 전달하세요. 여기서 `assertion()`은 호출될 때마다 새로 서명된 클라이언트 어설션을 반환해야 합니다. 어설션과 지원되는 알고리즘에 대한 설명은 [Express 페이지](/docs/sdk/express)를 참조하세요.

`fastifySession`은 루트 인스턴스에서 호출하세요. 캡슐화된 플러그인 안에서 호출하면 해당 플러그인의 라우트에만 적용됩니다.

`appBaseUrl`은 앱의 공개 주소입니다. SDK는 리디렉션 URI와 모든 리디렉션을 이 값으로 구성하며, 요청의 `Host`나 `X-Forwarded-*` 헤더로는 구성하지 않습니다. 따라서 `trustProxy` 옵션은 이 값에 영향을 주지 않습니다. 일반 `http`는 `localhost`에서만 허용됩니다.

옵션은 `createBff`가 실행될 때 검사되므로 설정 오류가 있으면 앱 시작 시점에 중단됩니다.

## 라우트

`fastifySession`은 라우트 세 개를 추가하고, 그 밖의 모든 요청에 대해 `onRequest` 훅에서 세션을 읽습니다.

| 라우트 | 기능 |
|---|---|
| `GET /auth/login` | 로그인을 시작합니다. 선택적 쿼리 매개변수는 `returnTo`(사용자가 도착할 위치)와 `organization`(특정 조직으로 로그인)입니다. |
| `GET /auth/callback` | 로그인을 완료하고 세션 쿠키를 설정합니다. |
| `GET` 또는 `POST /auth/logout` | KeenAuth에서 로그아웃하고 앱으로 돌아갑니다. |

로그인은 PKCE(S256)를 사용하는 인가 코드 흐름을 사용합니다. 환경에서 지원하는 경우 요청은 서버로 푸시됩니다(PAR). 콜백에서 SDK는 `state`, `iss`, 그리고 ID 토큰의 `nonce`를 확인합니다. 로그인이 성공할 때마다 새 세션이 생성되며, 이전 세션은 폐기됩니다.

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

## 라우트 보호

`fastifyRequireAuth(bff)`는 세션이 있는 요청만 통과시키는 `preHandler`입니다.

- 세션이 없는 페이지 요청(`GET` 또는 `HEAD`)은 `returnTo`와 함께 `/auth/login`으로 리디렉션됩니다.
- `/api/…` 경로 요청이나 다른 메서드의 요청은 문제 문서와 함께 `401`을 받습니다. 모든 경로에서 `401`로 응답하려면 `{ api: true }`를 전달하세요.

`POST`, `PUT`, `PATCH`, `DELETE` 등 안전하지 않은 메서드의 경우 브라우저는 요청이 앱에서 왔음을 나타내야 합니다. 즉, `Sec-Fetch-Site: same-origin`이거나 `Origin` 헤더가 앱의 origin과 같아야 합니다. 그 밖의 요청은 `403`을 받습니다. 이는 `SameSite=Lax`에 더해 사이트 간 요청 위조(CSRF)를 방지합니다.

## 세션 사용 및 API 호출

`request.keenauth`에는 다음이 포함됩니다.

- `session`: `null` 또는 로그인한 사용자. `session.user`에는 `subject`, `email`, `name`, `orgId`, `orgRoles` 같은 타입이 지정된 ID 토큰 클레임이 있으며, 모든 클레임은 `session.user.claims`에 있습니다. 세션 객체에는 어떤 토큰도 포함되지 않습니다.
- `accessToken`: 서버 측 호출에 사용할 수 있는 유효한 액세스 토큰 또는 `null`.

```ts
app.get("/api/orders", { preHandler: fastifyRequireAuth(bff) }, async (request, reply) => {
  const upstream = await fetch("https://api.acme.example/v1/orders", {
    headers: { Authorization: `Bearer ${request.keenauth!.accessToken}` },
  });
  return reply.code(upstream.status).send(await upstream.json());
});
```

TypeScript에서는 속성을 한 번 선언하세요.

```ts
import type { SessionContext } from "@keenauth/node/bff";

declare module "fastify" {
  interface FastifyRequest {
    keenauth?: SessionContext | null;
  }
}
```

액세스 토큰이 60초 안에 만료되면 SDK는 핸들러가 실행되기 전에 토큰을 갱신합니다. 갱신할 때마다 리프레시 토큰이 교체되고, 응답에 새 세션 쿠키가 설정됩니다. 서버가 리프레시 토큰을 거부하면 세션이 종료됩니다. 일시적인 서버 오류가 발생하면 세션은 그대로 유지됩니다.

액세스 토큰을 브라우저로 보내지 마세요.

## 조직 전환

`bff.switchOrganizationPath("org_01j9…", "/dashboard")`는 `/auth/login`으로 연결되는 링크를 반환합니다. 이 링크를 사용하면 사용자가 해당 조직으로 다시 로그인합니다. KeenAuth가 멤버십을 확인합니다. 로그인이 끝나면 `session.user.orgId`에 해당 조직이 담깁니다.

## 로그아웃

폼에서 `/auth/logout`으로 `POST` 요청을 보내세요. SDK는 다음을 수행합니다.

1. 세션 쿠키를 삭제합니다(저장소를 사용하는 경우 저장된 세션도 삭제합니다).
2. 리프레시 토큰을 폐기합니다.
3. 브라우저를 환경의 로그아웃 엔드포인트로 보내고, 그곳에서 앱으로 돌아갑니다.

브라우저가 다른 사이트에서 온 요청으로 표시한 요청은 거부됩니다. `Sec-Fetch-Site`를 보내지 않는 브라우저는 자체 페이지에서 보낸 `POST`로만 로그아웃할 수 있습니다.

## 세션 저장소

기본적으로 전체 세션은 쿠키에 들어 있으며, `secret`에서 파생된 키를 사용해 AES-256-GCM으로 암호화되고 인증됩니다. 변경되거나 복사된 쿠키는 거부됩니다. 쿠키 이름은 `__Host-keenauth_session.0`이며, `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`로 설정됩니다. `http://localhost`에서는 `__Host-` 접두사와 `Secure`가 제외됩니다. 큰 세션은 최대 쿠키 세 개로 나뉩니다.

토큰을 서버에 보관하려면 저장소를 전달하세요.

```ts
import { createBff, type SessionStore } from "@keenauth/node/bff";

const store: SessionStore = {
  get: async (id) => JSON.parse((await redis.get(`sess:${id}`)) ?? "null"),
  set: async (id, session, ttl) => void (await redis.set(`sess:${id}`, JSON.stringify(session), { EX: ttl })),
  delete: async (id) => void (await redis.del(`sess:${id}`)),
};
const bff = createBff({ /* … */ store });
```

이 경우 쿠키에는 암호화된 임의의 세션 ID만 들어 있습니다. 로그인할 때마다 새 ID가 생성되고 이전 레코드는 삭제됩니다. 저장소에는 토큰이 보관되므로 접근을 제한하세요. `MemorySessionStore`는 개발 및 테스트 용도로만 사용하세요.

세션은 최대 7일(`sessionMaxAge`) 동안 유지되며, 토큰을 갱신해도 연장되지 않습니다. 비밀 값을 변경하려면 목록을 전달하세요. 예: `secret: [newSecret, oldSecret]`. `sessionMaxAge`가 지나면 이전 값을 제거하세요.

## 현재 제한 사항

- DPoP에 바인딩된 토큰과 백채널 로그아웃은 아직 처리되지 않습니다.
- 갱신 요청은 하나의 서버 프로세스 안에서만 공유됩니다. 여러 인스턴스를 사용하는 경우 애플리케이션 설정 `refresh_reuse_interval_s`(기본값 5초)를 통해 동시 갱신 요청이 성공할 수 있습니다. 이 값을 `0`으로 설정하면 두 번째 갱신 요청이 세션을 종료합니다.
