본문으로 건너뛰기
언어

@plott/webkit-auth0.4.0

Next.js App Router 인증 코어 — BFF 세션, proxy 토큰 갱신, 인증 라우트, @plott/webkit-api 클라이언트에 끼우는 인증 플러그인, 브라우저 로그인(리다이렉트·팝업·네이티브 브릿지)

설치

pnpm
pnpm add @plott/webkit-auth@0.4.0

진입점(exports)

import타입파일
@plott/webkit-auth./dist/index.d.ts./dist/index.js
@plott/webkit-auth/server./dist/server/index.d.ts./dist/server/index.js
@plott/webkit-auth/proxy./dist/proxy/index.d.ts./dist/proxy/index.js
@plott/webkit-auth/client./dist/client/index.d.ts./dist/client/index.js
@plott/webkit-auth/kr./dist/kr/index.d.ts./dist/kr/index.js

Next.js(App Router) 인증 코어입니다. 세션 쿠키·proxy 토큰 갱신·인증 라우트·BFF·브라우저 로그인(리다이렉트·팝업·네이티브 브릿지·비밀번호)과, @plott/webkit-api 클라이언트에 끼우는 인증 플러그인(auth.bearer())을 맡습니다. 세션 저장 방식과 토큰 발급자는 구현 패키지를 끼워 정합니다 — 보통 @plott/webkit-auth-session-cookie 와 @plott/webkit-auth-oauth.

서드파티 런타임 의존성이 없습니다. peer: @plott/webkit-api, next >=16.4.1 <17(16.4.1 아래는 보안 패치가 빠진 버전 — GHSA-673q-mjm6-r5xr 등), react ^19.3.

진입점

import어디서주요 export
@plott/webkit-auth어디서나defineAuthConfig, 타입·포트(SessionStore·AuthBackend·PasswordResult), 오류(AuthError·AuthRequiredError·ApiError), standardClaims, readClaims
@plott/webkit-auth/server앱 루트 auth.ts(서버 컴포넌트·서버 액션·라우트 핸들러·proxy 가 가져간다)createAuth(config) → handlers, proxy, bearer, getContext, getSession, getSessionView, requireSession, redirectToLogin, handleAuthError, bff, linked, runWithAuthContext. 로그인 수단 defaultMethods·socialLogin·verificationCode·nativeToken·passwordLogin·webviewHandoff, 포트 타입 AuthMethod
@plott/webkit-auth/proxy설정을 따로 두는 앱의 proxy.tscreateAuthProxy(config)(auth.proxy 와 같다)
@plott/webkit-auth/client클라이언트 컴포넌트SessionProvider, useSession, authClient(signIn·signInWithPassword·startVerification·completeVerification·exchangeNativeToken·signOut), createBrowserApi, 타입 NativeBridge
@plott/webkit-auth/kr한국어 문구koMessages(messages 에 넘기는 한국어 세트)

설정(AuthConfig)

항목기본값뜻
sessionStore(필수)SessionStore 구현(세션을 쿠키 값으로 바꾸는 저장소). Auth.js 의 session(수명·전략 옵션)과 다릅니다 — 수명은 sessionMaxAge
backend(필수)AuthBackend 구현
methodsdefaultMethods()(소셜·네이티브 토큰)열 로그인 수단. 인증 코드·비밀번호는 백엔드의 포트를 넘겨 켭니다 — defaultMethods(backend)(oauth() 의 결과처럼 verification·password 포트를 가진 값). WebView 핸드오프는 [...defaultMethods(backend), webviewHandoff()]
messages영어서버가 그리는 HTML(팝업 결과 창, 매직 링크 확인 화면, 폼 릴레이 버튼)의 문구. 일부만 덮어쓸 수 있고 한국어는 @plott/webkit-auth/kr 의 koMessages
origin요청 URL브라우저가 보는 오리진. redirect_uri 를 이것으로 만든다 — 로드밸런서 뒤 운영에서는 반드시 적는다
basePath/auth인증 라우트 자리(app/<basePath>/[...auth]/route.ts). 앱 기준 경로 — Next basePath 는 모듈이 붙인다
cookieName__Host-webkit.session(https) / __Secure-webkit.session(https + cookieDomain) / webkit.session(http)한 도메인에 앱이 둘이면 다르게
cookieDomain없음(이 호스트 전용)상위 도메인 쿠키(예: example.com). 하위 도메인 앱끼리 세션을 나누거나 다른 앱이 읽게 할 때만
secureCookiestruehttp 로컬 개발만 false
sessionMaxAge30일세션 쿠키 수명(초)
pages/login·/login·/·/login·error·afterLogin·afterLogout
linkedSessions없음다른 앱의 세션(읽기 전용). { 이름: { cookieName, sessionStore, issuerId } } → auth.linked(이름)
verificationFields없음(빈 목록)인증 코드 발송에서 발급자로 넘길 필드. 발급자가 받는 필드를 앱이 직접 적습니다(예: purpose·channel·target·locale)
refreshSkewSeconds60만료 몇 초 전부터 갱신(토큰 수명의 절반을 넘지 않는다)
clientIp{ trustedProxyHops: 1 }사용자 IP 를 읽는 법. X-Forwarded-For 오른쪽에서 믿는 프록시 수번째, 또는 header
requestIdFromx-request-id앞단(로드밸런서·게이트웨이)이 요청 ID 를 싣는 헤더. proxy 가 이 값을 이어 렌더로 넘긴다

브라우저가 버리는 쿠키 조합(__Host- + Domain, http + __Secure-, 오리진을 덮지 않는 Domain)과 겹치는 수단 라우트는 시작할 때 멈춥니다. 운영(NODE_ENV=production)에서 origin 을 비우면 시작할 때 경고합니다.

업스트림 주소·로케일·전달 헤더는 이 설정에 없습니다 — @plott/webkit-api 클라이언트의 설정과 플러그인입니다.

인증 플러그인(auth.bearer())

ts
import { createApiClient } from "@plott/webkit-api";import { requestContext } from "@plott/webkit-api/next";import type { Auth } from "@plott/webkit-auth/server";
declare const auth: Auth;
export const api = createApiClient({ baseUrl: "https://api.example.com", use: [requestContext(), auth.bearer()] });
동작내용
실을 토큰요청에 실려 온 Authorization: Bearer(네이티브 앱) → 없으면 세션의 액세스 토큰 → 없으면 토큰 없이
만료세션 토큰이 만료됐으면 보내지 않고 AuthRequiredError("token_expired")(서버 컴포넌트는 쿠키를 쓸 수 없어 여기서 갱신하지 않는다)
401이 플러그인이 맡은 호출의 401 은 AuthRequiredError("session_expired") → auth.handleAuthError(e) 가 로그인·갱신 왕복으로
호출자 헤더호출자가 Authorization 을 직접 넣으면 건드리지 않고, 그 호출의 401 도 ApiError 그대로
옵션기본값뜻
token(ctx)없음이 서버용 토큰을 직접 고른다(예: 이 서버 전용으로 바꾼 토큰). undefined 면 토큰 없이
acceptIncomingBearertrue요청에 실려 온 Bearer 를 이 서버로 넘기나
rejectsSessiontrue401 을 AuthRequiredError 로 바꾸나
headerauthorization토큰을 싣는 헤더. 업스트림이 다른 헤더로 받으면 바꾼다(호출자 헤더 판정도 이 이름)
format(token)Bearer <토큰>헤더 값

토큰을 받은 서버는 그 토큰을 다른 서버에 다시 쓸 수 있습니다. 이 세션의 토큰을 받아야 하는 서버(토큰의 aud)의 클라이언트에만 끼우세요.

다른 앱의 세션은 auth.linked(이름).bearer() 입니다. 이 앱의 요청 Bearer 는 넘기지 않고, 401 은 ApiError 그대로입니다(이 앱의 세션을 끝내지 않습니다). 설정에 없는 이름은 «언제나 없음»으로 동작하고 시작할 때 경고합니다.

배치·시스템 호출은 auth.runWithAuthContext({ bearer }, fn) 으로 그 일의 토큰을 주거나, runWithIncoming(값, fn, { mode: "replace" })(@plott/webkit-api/next)로 사용자 요청을 보지 않게 합니다.

인증 라우트

코어: <basePath>/session, providers, refresh(?returnTo=&reason=rejected&proof=), logout(POST).

기본 수단:

수단라우트
socialLogin()login/:provider(?returnTo=&login_hint=&display=popup), callback/:provider(GET·POST)
verificationCode(port)verify/start, verify/complete, magic(GET 확인 화면·POST 소비)
nativeToken()exchange
passwordLogin(port)password

verificationCode·passwordLogin 은 VerificationPort·PasswordPort(타입은 @plott/webkit-auth)를 받습니다. 포트가 없으면(undefined) 시작할 때 멈추고, 수단을 켜지 않은 라우트는 404 입니다. webviewHandoff() 는 라우트가 없고 proxy 단계만 있습니다.

로그인 수단 만들기(AuthMethod)

ts
import { NextResponse } from "next/server";import type { AuthMethod } from "@plott/webkit-auth/server";import type { Session } from "@plott/webkit-auth";
declare function myIssuer(body: Record<string, unknown>, current: Session | null): Promise<Session>;
export const hello: AuthMethod = {  id: "hello",  routes: {    hello: {      POST: async (req, kit) => {        kit.requireSameOrigin(req);        const session = await myIssuer(await kit.readJson(req), await kit.readSession(req));        return kit.respondWithSession(req, NextResponse.json({ ok: true }), session);      },    },  },};

createAuth({ ..., methods: [...defaultMethods(), hello] }) 로 켭니다.

kit(RouteKit)은 쿠키 속성·Next basePath·오류 응답 모양을 맞춰 줍니다: readSession, respondWithSession, requireSameOrigin, readJson, jsonError, errorRedirect, loginRedirect, loginOutcome, clientIp, pub·at. proxy 단계는 proxy(req, { cfg, session, headers }) 가 { session, replaced } 를 돌려줍니다.

붙이기

auth.ts 에서 한 번 만들고 proxy.ts·라우트·화면이 같은 auth 를 씁니다.

ts
import { createAuth } from "@plott/webkit-auth/server";import { oauth, type OAuthProvider, type ProviderIssuer } from "@plott/webkit-auth-oauth";import { cookieSession } from "@plott/webkit-auth-session-cookie";
declare const origin: string;declare const secret: string;declare const issuer: ProviderIssuer;declare const providers: OAuthProvider[];
export const auth = createAuth({ origin, sessionStore: cookieSession({ secrets: [secret] }), backend: oauth({ issuer, providers }) });

app/auth/[...auth]/route.ts 는 export const { GET, POST } = auth.handlers, proxy.ts 는 export const proxy = auth.proxy(또는 auth.proxy(req, next))입니다. 서버 컴포넌트·서버 액션은 auth.requireSession({ kind }) 로 세션을 요구하고, 업스트림 호출을 try/catch 로 감싸 auth.handleAuthError(e) 에 넘깁니다. 클라이언트에는 getSessionView() 의 값을 <SessionProvider session={...} basePath> 로 내립니다. basePath 는 브라우저 경로(Next basePath 포함)입니다.

auth.bff({ api, allow }) 는 클라이언트 컴포넌트가 업스트림을 부르는 같은 오리진 통로입니다. 토큰은 서버에서만 붙고, allow(path, method) 가 거짓인 경로는 404 입니다.

포트

포트구현약속
SessionStore@plott/webkit-auth-session-cookie 등decode 는 열지 못하면(위조·만료·모르는 키) 던지지 않고 null. destroy 는 로그아웃·세션 만료·주체가 바뀌는 로그인에서만 부르고 토큰 갱신에는 부르지 않습니다. seal/unseal 은 로그인 트랜잭션처럼 잠깐 쓰는 값을 세션과 다른 키로 봉인합니다
AuthBackend@plott/webkit-auth-oauth 등refresh 는 세션이 끝났으면 AuthRequiredError, 일시 장애면 그 밖의 오류. 같은 리프레시 토큰의 동시 갱신 합치기는 코어가 합니다. exchange 가 없으면 해당 라우트가 꺼집니다. 인증 코드(verificationStart·verificationComplete)와 비밀번호(passwordLogin)는 코어 포트가 아니라 VerificationPort·PasswordPort 로 따로 받습니다
AuthMethoddefaultMethods() 와 직접 만든 수단라우트(첫 세그먼트 → 처리기)와 선택적 proxy 단계를 더합니다. 수단끼리 또는 코어 라우트(session·providers·refresh·logout)와 이름이 겹치면 시작할 때 멈춥니다

오류 코드

?error=<code> 로 오류 페이지에 전달되며 문구는 앱이 정합니다.

코드뜻
login_expired콜백의 state 에 맞는 트랜잭션 쿠키가 없음(10분 초과, 다른 브라우저에서 콜백 열기, 쿠키 누락)
state_mismatchstate 가 다름(위조 또는 탭 섞임)
provider_deniedIdP 가 error= 로 돌려보냄(사용자 취소 포함)
exchange_failed코드·토큰 교환 실패
invalid_code · code_expired · too_many_attempts인증 코드 불일치 · 만료·이미 사용됨 · 시도 횟수 초과
invalid_credentials아이디·비밀번호 불일치(어느 쪽인지 알리지 않음)
session_expired · token_expired리프레시 토큰이 죽음 · 액세스 토큰이 만료됐는데 이 자리에서 갱신할 수 없음(RSC 렌더 중)
forbidden_origin다른 사이트에서 온 POST
unknown_provider · bad_request모르는 제공자 · 잘못된 요청
invalid_token받은 Bearer 토큰을 믿을 수 없음(이유는 @plott/webkit-auth-oauth/bearer 의 BearerError.reason)

기본으로 지키는 것

기본막는 사고
토큰은 브라우저로 나가지 않음(SessionView 에는 요약만)클라이언트 번들·로그로 토큰이 새는 것
세션 쿠키 이름·도메인 조합이 브라우저 규칙을 어기면 시작할 때 멈춤(__Host-+Domain, http+__Secure-, 오리진을 덮지 않는 Domain, 자기 쿠키와 같은 linked 이름)브라우저가 쿠키를 말없이 버려 «로그인이 안 됨»으로만 보이는 것
cookieDomain 을 켜면 하위 도메인 아무 곳이나 쿠키를 덮어쓸 수 있음(쿠키 토싱)을 문서화하고, 켠 뒤 남은 호스트 전용 같은 이름 쿠키는 세션을 쓰는 응답에서 지움어느 값이 읽힐지 브라우저마다 달라지는 것
운영에서 origin 이 없고 trustHost 도 아니면 시작할 때 멈춤(next build 단계는 제외)로드밸런서 뒤에서 내부 주소가 읽혀 redirect_uri·복귀 주소·CSRF 오리진이 어긋나는 것
세션 쿠키는 4096바이트 한도를 넘지 않게 3800자 조각(name.0…)으로 나눔. 조각이 3개를 넘으면 id_token 부터 버리고, 그래도 넘으면 세션을 끝냄브라우저가 큰 쿠키를 말없이 버리는 것, 요청 헤더 한도 초과(431/400)
SessionStore.decode 가 던져도 «세션 없음»으로 보고 쿠키는 건드리지 않음저장소 장애가 모든 화면·로그인 경로를 500 으로 만드는 것
풀린 값의 모양이 지금 빌드와 다르면(v·발급자·토큰·주체) «세션 없음»이전 빌드가 봉인한 쿠키가 proxy·화면을 TypeError 500 으로 만드는 것
같은 리프레시 토큰의 동시 갱신을 한 번으로 합치고 결과를 30초(실패는 5초) 기억. 끝나지 않은 갱신은 120초 상한까지 지우지 않음. 프로세스 전역 심볼에 두어 proxy 와 라우트 핸들러가 하나를 씀회전형 리프레시 토큰이 병렬 요청에서 «재사용»으로 보여 세션 전체가 폐기되는 것(인스턴스가 여럿이면 발급자 쪽 유예 시간이 필요합니다. AS 계약)
만료 skew 는 토큰 수명의 절반을 넘지 않음수명이 skew 보다 짧을 때 매 요청이 갱신을 부르는 것
proxy 가 갱신하면 요청 Cookie 헤더를 새 값으로 다시 써서 같은 요청의 렌더가 새 세션을 봄. 다른 쿠키는 원문 그대로렌더가 만료된 토큰으로 업스트림을 부르는 것, 디코드된 값을 이어 붙여 쿠키가 쪼개지는 것
갱신 실패 중 세션의 끝(AuthRequiredError)만 세션을 지우고, 발급자 장애는 세션을 두고 지나감발급자 장애로 모든 사용자가 로그아웃되는 것
로그인 후 복귀 주소는 같은 오리진 경로만(//·/\·탭·점 세그먼트로 다른 오리진이 되는 값, 인증 경로 자신은 거절, _rsc 제거)오픈 리다이렉트, 로그인 왕복 무한 고리
POST 는 Origin(없으면 Sec-Fetch-Site)이 같은 오리진이어야 함. 둘 다 없으면 브라우저가 아니므로 통과. Origin: null 은 Sec-Fetch-Site 로 판정하고 근거가 없으면 거절로그인 CSRF, Referrer-Policy: no-referrer 화면의 폼 POST 오탐
state 마다 트랜잭션 쿠키(...tx.<state 앞 16자>)를 따로 두고 콜백 경로로 좁힘. 지울 때도 같은 속성탭 두 개에서 동시에 로그인해도 서로 덮지 않음, __Secure- 이름이 Secure 없이 지워지지 않는 것
state 앞머리에 창 방식(p/r)을 실음트랜잭션 쿠키를 잃은 콜백도 팝업이었는지 알아 «창 닫기»로 돌려줄 수 있음
인증 라우트가 돌려주는 HTML(매직 링크 확인·form_post 중계·팝업 결과)은 X-Frame-Options: DENY + frame-ancestors 'none'다른 사이트의 틀(iframe)에 넣어 «로그인»을 누르게 하는 것
매직 링크 GET 은 소비하지 않고 확인 화면만, POST 가 소비메일 보안 스캐너가 링크를 먼저 열어 코드를 써 버리는 것
form_post(Apple) 콜백은 같은 사이트 POST 로 한 번 더 중계(헤더로 고르지 않음)크로스사이트 POST 에 SameSite=Lax 쿠키가 실리지 않는 것, Sec-Fetch-Site 를 보내지 않는 브라우저
팝업 결과 HTML 의 JSON 은 < 를 이스케이프</script> 로 빠져나가는 값
브라우저에는 오류 코드만 줌(메시지는 서버 로그에)내부 호스트·AS 설명이 새는 것
로그아웃은 폐기가 실패해도 쿠키를 지움폐기 실패로 로그아웃이 막히는 것
업스트림 401 은 쿠키를 지우는 갱신 왕복으로(reason=rejected&proof=<토큰 지문>). 지문이 지금 토큰의 것이거나 같은 사이트에서 온 요청일 때만 끊고, 그 밖은 끊지 않음로그인 화면이 돌려보내는 고리, 다른 사이트가 만든 주소로 로그아웃시키는 CSRF
auth.bearer(): 호출자가 자기 Authorization 을 넣은 호출은 건드리지 않고 그 호출의 401 도 ApiError 그대로다른 서버용 자격이 이 세션의 것으로 취급되는 것
auth.linked(이름): 읽기 전용, 갱신·삭제 없음, 만료된 세션은 «없음», 이 앱의 요청 Bearer 를 넘기지 않음, 설정에 없으면 «언제나 없음»(시작할 때 경고)같은 리프레시 토큰을 두 앱이 돌려 회전형 발급자가 세션을 끊는 것, 설정이 없는 배포에서 앱이 멈추는 것
BFF: 세그먼트 .·..·..;·/·\·%·?·# 는 404, 허용 검사한 경로와 실제로 나갈 경로가 같을 때만 호출, 쓰기는 같은 오리진만, 본문은 바이트 그대로, 업스트림 401 은 session_expired 한 모양, 도달 불가는 502/504, 빠진 JS 값은 400허용 검사 뒤 다른 경로로 읽히는 입력(%252e%252e·Tomcat 경로 파라미터), 업스트림 본문·내부 주소가 브라우저로 새는 것
비밀번호는 응답·로그·세션 어디에도 남기지 않음. 아이디 1~256자, 비밀번호 1~1024자해시 비용 공격, 비밀번호 유출
WebView 핸드오프가 토큰을 거절하거나 발급자가 장애여도 남은 웹 세션을 이어 쓰지 않음. 쓴 Authorization 헤더는 렌더로 넘기지 않음앱에서 계정을 바꿨는데 이전 사람의 화면이 열리는 것
클라이언트: signIn 의 팝업은 클릭과 같은 틱에서 연다(await 전에). 차단되면 onBlocked 에 따라 리다이렉트 또는 popup_blocked. 창이 닫혀 보이면 오류가 아니라 closed(IdP 의 COOP 로 opener 연결만 끊겼을 수 있어 사용자가 닫은 것과 구분되지 않음)팝업 차단, 정상 로그인을 오류로 보여 주는 것
클라이언트: auto 는 브릿지가 있고 제공자가 네이티브 토큰을 받으면 native, 앱 WebView 웹 로그인을 막는 제공자(Google·Meta)인데 네이티브 토큰도 못 받으면 native_required, 그 밖은 redirect앱 WebView 에서 disallowed_useragent 오류 화면으로 보내는 것
클라이언트: signOut 은 폼 POST(IdP 로그아웃 리다이렉트를 브라우저가 따라감). 다른 탭에는 쿠키가 지워진 뒤 세션이 «로그인 안 됨»으로 내려올 때 알림(저장소가 막혀 있어도 폼은 나감)지워지기 전에 알리면 다른 탭이 남은 세션을 다시 읽는 것
네이티브 브릿지: 앱의 응답 함수는 창에 하나이고 대기 목록도 거기에 둠브릿지를 여러 번 만들거나 모듈이 다시 평가돼도(HMR) 응답이 요청한 쪽에 닿음

그 밖

  • 서드파티 런타임 의존성이 없습니다. 인증 컨텍스트(getContext)는 runWithAuthContext 로 덮어쓴 값이 있으면 그것이, 없으면 들어온 요청에서 매번 파생한 값이 쓰입니다. 덮어쓴 값이 있을 때는 들어온 요청의 쿠키·Authorization 을 보지 않습니다.
  • 쿠키를 여는 일은 한 렌더 안에서 값마다 한 번만 합니다(React cache).
  • 앱 WebView 와의 통신(네이티브 로그인 포함)은 @plott/webkit-native 하나가 맡습니다. authClient 는 기본으로 브릿지를 쓰지 않고, authClient({ nativeBridge: asAuthBridge(bridge) }) 로 연결합니다. 메시지 모양은 네이티브 브릿지 계약 에 있습니다.
  • 팝업·로그아웃 알림에 쓰는 이름(메시지 종류·채널·창 이름·저장소 키·결과 요소 id)은 모두 접두 webkit-auth 로 시작합니다.
  • 증상별 대응은 트러블슈팅에 있습니다.
  • 각 항목의 동작은 test/ 의 양성·음성 테스트가 기록합니다.