Fastify 세션
@keenauth/node/bff는 Fastify 5 애플리케이션에 로그인 기능을 추가합니다. 서버가 OAuth 클라이언트 역할을 하며 모든 토큰을 서버에 보관합니다. 브라우저에는 암호화된 HttpOnly 세션 쿠키만 전달되며, 액세스 토큰, 리프레시 토큰, ID 토큰은 브라우저에 노출되지 않습니다. 이는 BFF(backend-for-frontend) 패턴입니다. 이 패키지는 Fastify를 import하지 않으므로 추가 의존성이 생기지 않습니다. 아직 npm에 게시되지는 않았습니다.
대신 베어러 토큰을 받는 API를 보호하려면 API 보호를 참조하세요.
시작하기 전에
- 대시보드에서 환경에 웹 애플리케이션을 만드세요.
- 클라이언트 인증 방식을 선택하세요. 클라이언트 비밀 값(
client_secret_basic)을 사용하거나, 공개 키를 애플리케이션에 등록한 키 쌍으로private_key_jwt를 사용할 수 있습니다. 공유 비밀 값이 서버 밖으로 나가지 않으므로private_key_jwt를 권장합니다. - 리디렉션 URI
https://app.acme.example/auth/callback과 로그아웃 후 리디렉션 URIhttps://app.acme.example/를 추가하세요. - 32자 이상의 쿠키 비밀 값을 생성하세요. 예를 들어
openssl rand -base64 32를 사용할 수 있습니다. 저장소가 아닌 비밀 값 저장소에 보관하세요.
설정
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 페이지를 참조하세요.
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.
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에서는 속성을 한 번 선언하세요.
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는 다음을 수행합니다.
- 세션 쿠키를 삭제합니다(저장소를 사용하는 경우 저장된 세션도 삭제합니다).
- 리프레시 토큰을 폐기합니다.
- 브라우저를 환경의 로그아웃 엔드포인트로 보내고, 그곳에서 앱으로 돌아갑니다.
브라우저가 다른 사이트에서 온 요청으로 표시한 요청은 거부됩니다. Sec-Fetch-Site를 보내지 않는 브라우저는 자체 페이지에서 보낸 POST로만 로그아웃할 수 있습니다.
세션 저장소
기본적으로 전체 세션은 쿠키에 들어 있으며, secret에서 파생된 키를 사용해 AES-256-GCM으로 암호화되고 인증됩니다. 변경되거나 복사된 쿠키는 거부됩니다. 쿠키 이름은 __Host-keenauth_session.0이며, Secure, HttpOnly, SameSite=Lax, Path=/로 설정됩니다. http://localhost에서는 __Host- 접두사와 Secure가 제외됩니다. 큰 세션은 최대 쿠키 세 개로 나뉩니다.
토큰을 서버에 보관하려면 저장소를 전달하세요.
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으로 설정하면 두 번째 갱신 요청이 세션을 종료합니다.