Next.js SDK
@keenauth/nextjs는 Next.js App Router 애플리케이션에 로그인 기능을 추가합니다. 애플리케이션이 OAuth 클라이언트이며 모든 토큰을 서버에 보관합니다. 브라우저는 암호화되고 HttpOnly 속성이 설정된 세션 쿠키만 받으며 액세스 토큰, 리프레시 토큰, ID 토큰은 전혀 볼 수 없습니다. 이는 백엔드 포 프런트엔드(BFF) 패턴입니다. 이 패키지에는 런타임 의존성이 없습니다. 아직 npm에 게시되지 않았습니다.
시작하기 전에
- 대시보드에서 환경에 웹 애플리케이션을 만드십시오.
private_key_jwt클라이언트 인증을 선택하고 공개 키를 등록하십시오. 클라이언트 시크릿도 사용할 수 있습니다.- 리디렉션 URI
https://app.acme.example/auth/callback과 로그아웃 후 리디렉션 URIhttps://app.acme.example/를 추가하십시오. - 최소 32자 이상의 쿠키 시크릿을 생성하십시오. 예를 들어
openssl rand -base64 32를 사용할 수 있습니다. 저장소가 아니라 시크릿 저장소에 보관하십시오.
인스턴스 생성
// 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에서만 허용됩니다.
옵션은 인스턴스를 만들 때 검사되므로, 설정 오류가 있으면 시작 단계에서 애플리케이션이 중지됩니다.
라우트 추가
// 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과 같은 다른 값은 무시되며 사용자는 /로 이동합니다.
페이지 보호 및 토큰 갱신
// 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가 일부 경로를 놓칠 수 있습니다.
세션 읽기
서버 컴포넌트에서는 쿠키 저장소를 전달하십시오.
// 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 호출
서버 컴포넌트에서는 쿠키 저장소에서 액세스 토큰을 가져오십시오.
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으로도 갱신할 수 있습니다.
// 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)도 추가합니다.
const res = await auth.authorizedFetch(await cookies(), "https://api.acme.example/v1/orders");
액세스 토큰을 클라이언트 컴포넌트에 전달하지 마십시오. API는 서버에서 호출하고, 브라우저에는 데이터만 보내십시오. API에서 이러한 토큰을 검증하는 방법은 API에서 토큰 검증하기를 참조하십시오.
로그아웃
/auth/logout으로 링크를 연결하거나, 폼에서 이 경로로 POST 요청을 보내십시오. SDK는 다음 작업을 수행합니다.
- 세션 쿠키를 삭제합니다.
- 리프레시 토큰을 취소합니다.
- 브라우저를 환경의 로그아웃 엔드포인트로 보내고, 이 엔드포인트가 브라우저를 애플리케이션으로 돌려보냅니다.
브라우저가 다른 사이트에서 온 요청으로 표시한 요청은 거부되므로, 다른 사이트가 사용자를 로그아웃시킬 수 없습니다. Sec-Fetch-Site를 보내지 않는 브라우저에서는 애플리케이션 자체 페이지에서 보내는 POST로만 로그아웃할 수 있습니다. /auth/logout으로 POST하는 폼을 사용하는 것이 좋습니다.
송신자 제약 토큰(DPoP)
DPoP(RFC 9449)를 사용하면 액세스 토큰이 서버만 보유한 키에 바인딩됩니다. API 로그 등에서 토큰이 유출되더라도 해당 키 없이는 토큰을 사용할 수 없습니다.
- 애플리케이션용 ES256(P-256) 키를 하나 생성하고, 개인 JWK를 시크릿 저장소에 보관하십시오.
- 이를
dpop으로 전달하십시오.
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에 대한 암호화된 참조만 저장됩니다.
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 인터페이스를 구현하십시오.
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는 일치하는 세션을 삭제하므로, 사용자의 다음 요청에는 세션이 없습니다.
https://app.acme.example/auth/backchannel-logout을 애플리케이션의 백채널 로그아웃 URI로 등록하십시오.- 이 라우트에 인터넷에서 접근할 수 있는지 확인하십시오. 프록시 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초, 애플리케이션 업데이트 참조)를 사용하면 두 번째 갱신이 성공합니다. 이 값을0으로 설정하면 두 번째 갱신이 세션을 종료합니다. sessionStore가 없으면 백채널 로그아웃을 사용할 수 없습니다. 이 경우 리프레시 토큰이 거부되면 서버에서 세션이 종료됩니다.