@lemondouble/lemon-auth 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/proxy/index.ts"],"sourcesContent":["import { NextRequest, NextResponse } from \"next/server\";\nimport {\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\n/**\n * 프록시는 라우팅 결정만 한다: 신선하면 통과, 아니면 복구 경로로.\n *\n * Next.js 공식 인증 가이드의 optimistic check 다. 프리페치를 포함한 모든\n * 요청에서 돌기 때문에 쿠키의 exp 만 읽고, 네트워크 · DB · 서명 검증을\n * 하지 않는다. 보안 판단은 DAL(getSession / requireAuth)이 한다.\n *\n * 갱신 주체는 restorePath 에 마운트된 <SessionRestore /> 하나뿐이다.\n * 공개 경로도 refresh 쿠키가 남아 있으면 복구를 거치게 해서, 만료된 세션이\n * \"로그아웃 상태로 렌더됐다가 뒤늦게 뒤집히는\" 화면을 없앤다. (0.8.x 까지는\n * 이 몫을 레이아웃의 <AutoTokenRefresh /> 가 맡았는데, 복구 경로와 갱신\n * 주체가 둘이 되는 문제가 있었다)\n *\n * 0.7.x 까지는 프록시가 직접 auth 서버로 갱신을 보냈다. 미들웨어는 요청마다\n * 별도 인스턴스로 떠서 직렬화가 안 되므로, 프리페치가 겹치면 같은 refresh\n * token 으로 갱신이 동시에 나가 회전 경쟁이 났고 계정 토큰이 통째로\n * 폐기되는 일이 반복됐다. 다시 여기에 네트워크 호출을 넣지 말 것.\n */\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nexport const DEFAULT_AUTH_BYPASS_PATHS = [\n \"/sw.js\",\n \"/service-worker.js\",\n \"/manifest.webmanifest\",\n \"/manifest.json\",\n \"/favicon.ico\",\n \"/robots.txt\",\n \"/sitemap.xml\",\n \"/browserconfig.xml\",\n \"/apple-touch-icon.png\",\n \"/apple-touch-icon-*\",\n \"/icon-*\",\n \"/icons/*\",\n];\n\nexport const DEFAULT_API_PATHS = [\"/api/*\"];\n\n/** 앱이 <SessionRestore /> 를 마운트해야 하는 기본 경로. */\nexport const DEFAULT_RESTORE_PATH = \"/auth/restore\";\n\nexport const PROXY_AUTH_ERROR = {\n UNAUTHORIZED: \"UNAUTHORIZED\",\n FORBIDDEN: \"FORBIDDEN\",\n} as const;\nexport type ProxyAuthErrorCode =\n (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n /** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */\n publicPaths?: string[];\n /** 프록시 자체를 건너뛸 경로. 정적 파일 등. */\n bypassPaths?: string[];\n /** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */\n apiPaths?: string[];\n loginRedirectUrl?: LoginRedirectUrl;\n /** <SessionRestore /> 를 마운트한 경로. 기본 `/auth/restore`. */\n restorePath?: string;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n loginRedirectUrl,\n restorePath = DEFAULT_RESTORE_PATH,\n } = options;\n const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n // 복구 경로 자신은 항상 통과. 여기서 복구 경로로 보내면 무한 루프다.\n if (pathname === restorePath) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, effectiveBypassPaths)) {\n return NextResponse.next();\n }\n\n if (isMockAuthEnabled()) {\n return NextResponse.next();\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n // --- 여기부터는 access token 이 없거나 만료가 임박한 요청 ---\n\n const isPublic = isPublicPath(pathname, publicPaths);\n\n if (isPublicPath(pathname, apiPaths)) {\n // API 는 리다이렉트를 따라가봐야 HTML 을 받을 뿐이다. 401 을 주고\n // 클라이언트가 복구하게 한다.\n if (isPublic) {\n return NextResponse.next();\n }\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n }\n\n // 서버 액션 등 비-GET 을 리다이렉트하면 액션 프로토콜이 깨진다.\n // 통과시키고 DAL 이 거부하게 둔다.\n if (request.method !== \"GET\") {\n return NextResponse.next();\n }\n\n // refresh 가 남아 있으면 아직 로그인이 풀린 게 아니다. 브라우저가\n // 갱신할 수 있도록 복구 경로로 보낸다. 프리페치가 이 리다이렉트를\n // 캐시에 굳혀도 해가 없다 — 복구 경로는 스스로 세션을 되살리고\n // next 로 되돌려 보내므로 목적지가 보존된다.\n if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {\n const restore = new URL(restorePath, request.url);\n restore.searchParams.set(\"next\", pathname + request.nextUrl.search);\n return NextResponse.redirect(restore);\n }\n\n if (isPublic) {\n return NextResponse.next();\n }\n\n return redirectToLogin(request, loginRedirectUrl);\n };\n}\n\n/**\n * exp 만 본다. 서명은 검증하지 않는다.\n *\n * 위조한 토큰으로 이 검사를 통과할 수는 있으나, 그래봐야 DAL 에서 걸린다.\n * 프록시의 판단은 \"어느 화면으로 보낼까\" 이지 \"권한이 있는가\" 가 아니다.\n */\nfunction isTokenFresh(token: string): boolean {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return false;\n const payload = JSON.parse(\n Buffer.from(parts[1], \"base64url\").toString()\n ) as { exp?: unknown };\n return (\n typeof payload.exp === \"number\" &&\n payload.exp > Math.floor(Date.now() / 1000) + REFRESH_BUFFER_SECONDS\n );\n } catch {\n return false;\n }\n}\n\nfunction isPublicPath(pathname: string, publicPaths: string[]): boolean {\n return publicPaths.some((p) => {\n if (p.endsWith(\"*\")) {\n return pathname.startsWith(p.slice(0, -1));\n }\n return pathname === p;\n });\n}\n\nfunction redirectToLogin(\n request: NextRequest,\n loginRedirectUrl: LoginRedirectUrl | undefined\n): NextResponse {\n const redirectUrl =\n typeof loginRedirectUrl === \"function\"\n ? loginRedirectUrl(request)\n : loginRedirectUrl;\n\n if (redirectUrl) {\n const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n return NextResponse.redirect(new URL(url, request.url));\n }\n return NextResponse.redirect(new URL(\"/\", request.url));\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/proxy/index.ts","../../src/proxy/restore-page.ts"],"sourcesContent":["import { NextRequest, NextResponse } from \"next/server\";\nimport {\n ACCESS_TOKEN_COOKIE,\n REFRESH_TOKEN_COOKIE,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\nimport { restorePageResponse } from \"./restore-page.js\";\n\n/**\n * 쿠키 exp 만 보고 라우팅만 한다. 서명 검증은 DAL, 갱신은 복구 화면의 브라우저 스크립트가 맡는다.\n * 여기에 네트워크 호출(갱신)을 넣지 말 것 — 프리페치마다 동시 갱신되어 refresh 회전 경쟁이 난다.\n */\n\nconst REFRESH_BUFFER_SECONDS = 60;\n\nconst AUTH_BYPASS_PATHS = [\n \"/sw.js\",\n \"/service-worker.js\",\n \"/manifest.webmanifest\",\n \"/manifest.json\",\n \"/favicon.ico\",\n \"/robots.txt\",\n \"/sitemap.xml\",\n \"/browserconfig.xml\",\n \"/apple-touch-icon.png\",\n \"/apple-touch-icon-*\",\n \"/icon-*\",\n \"/icons/*\",\n];\n\nexport const DEFAULT_API_PATHS = [\"/api/*\"];\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n /** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */\n publicPaths?: string[];\n /** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */\n apiPaths?: string[];\n loginRedirectUrl?: LoginRedirectUrl;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n loginRedirectUrl,\n } = options;\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n if (isPublicPath(pathname, AUTH_BYPASS_PATHS)) {\n return NextResponse.next();\n }\n\n if (isMockAuthEnabled()) {\n return NextResponse.next();\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n // --- 여기부터는 access token 이 없거나 만료가 임박한 요청 ---\n\n const isPublic = isPublicPath(pathname, publicPaths);\n\n if (isPublicPath(pathname, apiPaths)) {\n // API 는 리다이렉트를 따라가봐야 HTML 을 받을 뿐이다. 401 을 주고\n // 클라이언트가 복구하게 한다.\n if (isPublic) {\n return NextResponse.next();\n }\n return NextResponse.json(\n { code: \"UNAUTHORIZED\" },\n { status: 401 }\n );\n }\n\n // 서버 액션 등 비-GET 은 리다이렉트하면 깨지므로 DAL 이 거부하게 둔다.\n if (request.method !== \"GET\") {\n return NextResponse.next();\n }\n\n // refresh 가 남아 있으면 브라우저가 갱신하도록 복구 화면을 바로 응답한다. 공개 경로도 포함.\n // 소프트 내비게이션(RSC 요청)이 HTML 을 받으면 Next 라우터가 같은 주소로 전체 페이지 이동을 한다.\n if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {\n return restorePageResponse();\n }\n\n if (isPublic) {\n return NextResponse.next();\n }\n\n return redirectToLogin(request, loginRedirectUrl);\n };\n}\n\n/** exp 만 본다. 위조 토큰은 여기를 통과해도 DAL 에서 걸린다. */\nfunction isTokenFresh(token: string): boolean {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return false;\n const payload = JSON.parse(\n Buffer.from(parts[1], \"base64url\").toString()\n ) as { exp?: unknown };\n return (\n typeof payload.exp === \"number\" &&\n payload.exp > Math.floor(Date.now() / 1000) + REFRESH_BUFFER_SECONDS\n );\n } catch {\n return false;\n }\n}\n\nfunction isPublicPath(pathname: string, publicPaths: string[]): boolean {\n return publicPaths.some((p) => {\n if (p.endsWith(\"*\")) {\n return pathname.startsWith(p.slice(0, -1));\n }\n return pathname === p;\n });\n}\n\nfunction redirectToLogin(\n request: NextRequest,\n loginRedirectUrl: LoginRedirectUrl | undefined\n): NextResponse {\n const redirectUrl =\n typeof loginRedirectUrl === \"function\"\n ? loginRedirectUrl(request)\n : loginRedirectUrl;\n\n if (redirectUrl) {\n const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n return NextResponse.redirect(new URL(url, request.url));\n }\n return NextResponse.redirect(new URL(\"/\", request.url));\n}\n","import { NextResponse } from \"next/server\";\nimport { REFRESH_URL } from \"../constants.js\";\nimport winkImage from \"./restore-images/wink.webp\";\nimport booksImage from \"./restore-images/books.webp\";\nimport monitorImage from \"./restore-images/monitor.webp\";\nimport failureImage from \"./restore-images/failure.webp\";\n\n/** 일러스트 안 태블릿 화면의 위치(%). 이 영역만 투명하게 뚫려 있어서 뒤에 깐 스피너가 비친다. */\ninterface ScreenArea {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\ninterface RestoreIllustration {\n src: string;\n screen: ScreenArea;\n /** 가로로 긴 화면에서 키우는 배율. 앉은 포즈는 책이 잘리지 않게 덜 키운다. */\n wideScale: number;\n}\n\nconst ILLUSTRATIONS: RestoreIllustration[] = [\n {\n src: winkImage,\n screen: { left: 31.37, top: 41.46, width: 37.6, height: 14.31 },\n wideScale: 1.5,\n },\n {\n src: booksImage,\n screen: { left: 33.22, top: 37.54, width: 33.68, height: 13.46 },\n wideScale: 1.2,\n },\n {\n src: monitorImage,\n screen: { left: 18.69, top: 38.31, width: 60.09, height: 22.54 },\n wideScale: 1.5,\n },\n];\n\nconst FAILURE_ILLUSTRATION: RestoreIllustration = {\n src: failureImage,\n screen: { left: 32.74, top: 41.55, width: 34.52, height: 14 },\n wideScale: 1.5,\n};\n\nexport function restorePageResponse(): NextResponse {\n const illustration =\n ILLUSTRATIONS[Math.floor(Math.random() * ILLUSTRATIONS.length)];\n return new NextResponse(renderRestorePage(illustration), {\n headers: {\n \"content-type\": \"text/html; charset=utf-8\",\n \"cache-control\": \"no-store\",\n },\n });\n}\n\nfunction stageStyle({ screen, wideScale }: RestoreIllustration): string {\n return [\n `--screen-left: ${screen.left}%`,\n `--screen-top: ${screen.top}%`,\n `--screen-width: ${screen.width}%`,\n `--screen-height: ${screen.height}%`,\n `--wide-scale: ${wideScale}`,\n ].join(\"; \");\n}\n\n/**\n * 색과 폰트는 lemon-design-system 토큰 값이다. 화면이 바로 그려지도록 외부 CSS 를 기다리지 않는다.\n * 실패 일러스트는 네트워크가 나쁠 때 쓰이므로 따로 받지 않고 HTML 맨 끝에 같이 싣는다.\n */\nfunction renderRestorePage(illustration: RestoreIllustration): string {\n return `<!doctype html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"theme-color\" content=\"#12100E\">\n<title>세션 복원 중</title>\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/pretendard/1.3.9/static/pretendard.min.css\" media=\"print\" onload=\"this.media='all'\">\n<style>\n:root {\n --color-primary: #F0B90B;\n --color-primary-hover: #D4A30A;\n --color-bg: #12100E;\n --color-surface: #1C1816;\n --color-border: #2E2723;\n --color-fg: #F5F0EB;\n --color-fg-muted: #8D7E72;\n --color-warning: #F59E0B;\n --color-focus-ring: var(--color-fg-muted);\n --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif;\n --radius-lg: 8px;\n}\n* { margin: 0; box-sizing: border-box; }\nbody {\n min-height: 100dvh;\n overflow: hidden;\n background: var(--color-bg);\n color: var(--color-fg);\n font-family: var(--font-sans);\n}\n:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px; }\n[hidden] { display: none !important; }\n.stage {\n position: fixed;\n top: 0;\n left: 50%;\n height: 100dvh;\n aspect-ratio: 2 / 3;\n transform: translateX(-50%);\n}\n@media (min-aspect-ratio: 1/1) {\n .stage {\n height: calc(100dvh * var(--wide-scale));\n top: calc((1 - var(--wide-scale)) * 12dvh);\n }\n}\n.illustration { position: absolute; inset: 0; width: 100%; height: 100%; }\n.screen {\n position: absolute;\n left: var(--screen-left);\n top: var(--screen-top);\n width: var(--screen-width);\n height: var(--screen-height);\n display: grid;\n place-items: center;\n background: radial-gradient(circle, var(--color-surface), var(--color-bg) 75%);\n font-size: 13px;\n font-weight: 500;\n line-height: 1.5;\n text-align: center;\n}\n.state { display: flex; flex-direction: column; align-items: center; gap: 10px; }\n.spinner {\n width: 40px;\n height: 40px;\n border: 5px solid var(--color-border);\n border-top-color: var(--color-primary);\n border-radius: 50%;\n animation: spin 0.8s linear infinite;\n}\n@keyframes spin { to { transform: rotate(360deg); } }\n@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2.4s; } }\n.alert-icon { color: var(--color-warning); }\n.retry {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n display: flex;\n justify-content: center;\n padding: 72px 20px calc(36px + env(safe-area-inset-bottom));\n background: linear-gradient(to top, var(--color-bg) 40%, transparent);\n}\n.retry button {\n min-height: 40px;\n padding: 10px 20px;\n border: none;\n border-radius: var(--radius-lg);\n background: var(--color-primary);\n color: var(--color-bg);\n font: 600 14px/1 var(--font-sans);\n cursor: pointer;\n}\n.retry button:hover { background: var(--color-primary-hover); }\n</style>\n</head>\n<body>\n<div class=\"stage\" style=\"${stageStyle(illustration)}\">\n <div class=\"screen\">\n <div id=\"restoring\" class=\"state\" role=\"status\">\n <span class=\"spinner\" aria-hidden=\"true\"></span>\n <p>세션 복원 중...</p>\n </div>\n <div id=\"unavailable\" class=\"state\" role=\"alert\" hidden>\n <svg class=\"alert-icon\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\"/><line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\"/></svg>\n <p>인증 서버에<br>연결하지 못했습니다</p>\n </div>\n </div>\n <img class=\"illustration\" src=\"${illustration.src}\" alt=\"\" decoding=\"sync\">\n</div>\n<div id=\"retry\" class=\"retry\" hidden>\n <button type=\"button\" onclick=\"location.reload()\">다시 시도</button>\n</div>\n<script>\nfunction showUnavailable() {\n document.getElementById(\"restoring\").hidden = true;\n document.getElementById(\"unavailable\").hidden = false;\n document.getElementById(\"retry\").hidden = false;\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", showFailureIllustration);\n } else {\n showFailureIllustration();\n }\n}\n\n// 디코딩이 끝난 뒤 그림과 화면 위치를 한 번에 바꿔야 문구가 어긋난 자리에 잠깐 뜨지 않는다.\nfunction showFailureIllustration() {\n const failure = document.getElementById(\"failure-illustration\").dataset;\n const image = new Image();\n image.src = failure.src;\n image.decode().then(() => {\n document.querySelector(\".illustration\").src = image.src;\n document.querySelector(\".stage\").setAttribute(\"style\", failure.stageStyle);\n }, () => {});\n}\n\n// 401·403 은 세션이 죽은 것이고 auth 서버가 쿠키를 지웠으므로, 새로고침하면 프록시가 로그인으로 보낸다.\n// 그 밖의 실패는 서버·네트워크 사정이라 토큰이 살아 있을 수 있으므로 재시도만 권한다.\nfetch(\"${REFRESH_URL}\", { method: \"POST\", credentials: \"include\" }).then(\n (response) => {\n if (response.ok || response.status === 401 || response.status === 403) {\n location.reload();\n return;\n }\n showUnavailable();\n },\n showUnavailable\n);\n</script>\n<div id=\"failure-illustration\" hidden data-src=\"${FAILURE_ILLUSTRATION.src}\" data-stage-style=\"${stageStyle(FAILURE_ILLUSTRATION)}\"></div>\n</body>\n</html>\n`;\n}\n"],"mappings":"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","names":["NextResponse","NextResponse"]}
@@ -1,31 +1,13 @@
1
- import { A as AccessTokenClaims, L as LemonSession, a as LemonUser } from '../types-B5uRY2Si.js';
2
- export { U as UserProfile } from '../types-B5uRY2Si.js';
3
- import 'jose';
4
-
5
- declare function verifyAccessToken(): Promise<AccessTokenClaims | null>;
6
- declare function verifyAccessTokenString(token: string): Promise<AccessTokenClaims | null>;
1
+ import { L as LemonSession, a as LemonUser } from '../url-O6F1o0px.js';
2
+ export { l as loginUrl, p as profileUrl } from '../url-O6F1o0px.js';
7
3
 
8
4
  declare const getUser: () => Promise<LemonUser | null>;
9
- interface GetSessionOptions {
10
- clientId?: string;
11
- }
12
- declare function getSession(options?: GetSessionOptions): Promise<LemonSession>;
13
- declare function requireAuth(redirectTo?: string): Promise<LemonUser>;
14
- interface RequireClientOptions {
5
+ declare function getSession({ clientId, }: {
6
+ clientId: string;
7
+ }): Promise<LemonSession>;
8
+ declare function requireClient(clientId: string, { loginRedirectTo, unapprovedRedirectTo, }?: {
15
9
  loginRedirectTo?: string;
16
10
  unapprovedRedirectTo?: string;
17
- }
18
- declare function requireClient(clientId: string, optionsOrRedirectTo?: string | RequireClientOptions): Promise<LemonUser>;
19
-
20
- declare const AUTH_SERVER_URL = "https://auth.lemondouble.com";
21
- declare const JWKS_URL = "https://auth.lemondouble.com/.well-known/jwks.json";
22
- declare const LOGIN_URL = "https://auth.lemondouble.com/api/oauth2/google/login";
23
- declare const REFRESH_URL = "https://auth.lemondouble.com/api/token/refresh";
24
- declare const LOGOUT_URL = "https://auth.lemondouble.com/api/token/logout";
25
- declare const PROFILE_URL = "https://auth.lemondouble.com/api/user/me";
26
- declare const PROFILE_PAGE_URL = "https://auth.lemondouble.com/profile";
27
- declare const ACCESS_TOKEN_COOKIE = "lemon_access_token";
28
- declare const REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
29
- declare const DEVICE_ID_COOKIE = "device_id";
11
+ }): Promise<LemonUser>;
30
12
 
31
- export { ACCESS_TOKEN_COOKIE, AUTH_SERVER_URL, AccessTokenClaims, DEVICE_ID_COOKIE, type GetSessionOptions, JWKS_URL, LOGIN_URL, LOGOUT_URL, LemonSession, LemonUser, PROFILE_PAGE_URL, PROFILE_URL, REFRESH_TOKEN_COOKIE, REFRESH_URL, type RequireClientOptions, getSession, getUser, requireAuth, requireClient, verifyAccessToken, verifyAccessTokenString };
13
+ export { LemonSession, LemonUser, getSession, getUser, requireClient };
@@ -1,16 +1,17 @@
1
+ import {
2
+ loginUrl,
3
+ profileUrl
4
+ } from "../chunk-TKMZEMW7.js";
1
5
  import {
2
6
  ACCESS_TOKEN_COOKIE,
3
7
  AUTH_SERVER_URL,
4
- DEVICE_ID_COOKIE,
5
8
  JWKS_URL,
6
- LOGIN_URL,
7
- LOGOUT_URL,
8
- PROFILE_PAGE_URL,
9
- PROFILE_URL,
10
- REFRESH_TOKEN_COOKIE,
11
- REFRESH_URL,
12
9
  getMockAccessTokenClaims
13
- } from "../chunk-OX5MDVSR.js";
10
+ } from "../chunk-JIXVTKLG.js";
11
+
12
+ // src/server/user.ts
13
+ import { cache } from "react";
14
+ import { redirect } from "next/navigation";
14
15
 
15
16
  // src/server/verify.ts
16
17
  import { createRemoteJWKSet, jwtVerify } from "jose";
@@ -45,12 +46,16 @@ function errorCode(error) {
45
46
 
46
47
  // src/server/verify.ts
47
48
  var jwks = createRemoteJWKSet(new URL(JWKS_URL));
48
- async function verify(token) {
49
+ async function verifyAccessToken() {
50
+ const mockClaims = getMockAccessTokenClaims();
51
+ if (mockClaims) return mockClaims;
52
+ const cookieStore = await cookies();
53
+ const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
54
+ if (!token) return null;
49
55
  try {
50
56
  const { payload } = await jwtVerify(token, jwks, {
51
57
  issuer: AUTH_SERVER_URL,
52
- // 발급자(자택 k3s)와 검증자(Vercel)의 시계가 몇 초 어긋나면, 방금
53
- // 발급된 토큰이 nbf 미래로 거부되어 로그인 직후 로그아웃으로 보인다.
58
+ // 발급자(자택 k3s)와 검증자(Vercel)의 시계 어긋남 허용
54
59
  clockTolerance: 10
55
60
  });
56
61
  if (isAccessTokenClaims(payload)) return payload;
@@ -64,38 +69,6 @@ async function verify(token) {
64
69
  return null;
65
70
  }
66
71
  }
67
- async function verifyAccessToken() {
68
- const mockClaims = getMockAccessTokenClaims();
69
- if (mockClaims) return mockClaims;
70
- const cookieStore = await cookies();
71
- const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
72
- if (!token) return null;
73
- return verify(token);
74
- }
75
- async function verifyAccessTokenString(token) {
76
- const mockClaims = getMockAccessTokenClaims();
77
- if (mockClaims) return mockClaims;
78
- return verify(token);
79
- }
80
-
81
- // src/server/user.ts
82
- import { cache } from "react";
83
- import { redirect } from "next/navigation";
84
-
85
- // src/approval.ts
86
- var warnedMissingClientId = false;
87
- function isApprovedClient(approvedClients, clientId) {
88
- if (!clientId) {
89
- if (!warnedMissingClientId) {
90
- warnedMissingClientId = true;
91
- warnAuth(
92
- "clientId not provided \u2014 approval check is disabled (all users pass). \uC124\uC815 \uB204\uB77D\uC774 \uC544\uB2CC\uC9C0 \uD655\uC778\uD560 \uAC83."
93
- );
94
- }
95
- return true;
96
- }
97
- return approvedClients.includes(clientId) || approvedClients.includes("*");
98
- }
99
72
 
100
73
  // src/server/user.ts
101
74
  function claimsToUser(claims) {
@@ -122,43 +95,29 @@ var getSessionByClientId = cache(
122
95
  return { type: "authenticated", user };
123
96
  }
124
97
  );
125
- async function getSession(options = {}) {
126
- return getSessionByClientId(options.clientId);
127
- }
128
- async function requireAuth(redirectTo = "/") {
129
- const user = await getUser();
130
- if (user) return user;
131
- redirect(redirectTo);
98
+ async function getSession({
99
+ clientId
100
+ }) {
101
+ return getSessionByClientId(clientId);
132
102
  }
133
- async function requireClient(clientId, optionsOrRedirectTo = "/") {
134
- const options = typeof optionsOrRedirectTo === "string" ? { loginRedirectTo: optionsOrRedirectTo } : optionsOrRedirectTo;
103
+ async function requireClient(clientId, {
104
+ loginRedirectTo = "/",
105
+ unapprovedRedirectTo = `${AUTH_SERVER_URL}/error?code=FORBIDDEN`
106
+ } = {}) {
135
107
  const session = await getSession({ clientId });
136
- if (session.type === "none") {
137
- redirect(options.loginRedirectTo ?? "/");
138
- }
139
- if (session.type === "unapproved") {
140
- redirect(
141
- options.unapprovedRedirectTo ?? `${AUTH_SERVER_URL}/error?code=FORBIDDEN`
142
- );
143
- }
108
+ if (session.type === "none") redirect(loginRedirectTo);
109
+ if (session.type === "unapproved") redirect(unapprovedRedirectTo);
144
110
  return session.user;
145
111
  }
112
+ function isApprovedClient(approvedClients, clientId) {
113
+ if (!clientId) return false;
114
+ return approvedClients.includes(clientId) || approvedClients.includes("*");
115
+ }
146
116
  export {
147
- ACCESS_TOKEN_COOKIE,
148
- AUTH_SERVER_URL,
149
- DEVICE_ID_COOKIE,
150
- JWKS_URL,
151
- LOGIN_URL,
152
- LOGOUT_URL,
153
- PROFILE_PAGE_URL,
154
- PROFILE_URL,
155
- REFRESH_TOKEN_COOKIE,
156
- REFRESH_URL,
157
117
  getSession,
158
118
  getUser,
159
- requireAuth,
160
- requireClient,
161
- verifyAccessToken,
162
- verifyAccessTokenString
119
+ loginUrl,
120
+ profileUrl,
121
+ requireClient
163
122
  };
164
123
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/verify.ts","../../src/claims.ts","../../src/log.ts","../../src/server/user.ts","../../src/approval.ts"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\n/**\n * 검증과 진단을 한 곳에 모은다.\n *\n * 예전에는 아래 두 export 가 같은 try/catch 를 각자 갖고 있었다. 그러면\n * 로깅을 붙일 때도 벌마다 갈리고, 한쪽만 고치는 일이 생긴다.\n */\nasync function verify(token: string): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n // 발급자(자택 k3s)와 검증자(Vercel)의 시계가 몇 초 어긋나면, 방금\n // 발급된 토큰이 nbf 미래로 거부되어 로그인 직후 로그아웃으로 보인다.\n clockTolerance: 10,\n });\n if (isAccessTokenClaims(payload)) return payload;\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료는 갱신으로 이어지는 흔한 경로라 남기지 않는다. 나머지는\n // 원인이 전혀 다르다 — 특히 ERR_JWKS_NO_MATCHING_KEY 는 키 로테이션이나\n // JWKS 도달 실패이고, 이걸 위조 토큰과 뭉개면 전 사용자가 조용히\n // 로그아웃된 이유를 알 수 없게 된다.\n const code = errorCode(error);\n if (code !== \"ERR_JWT_EXPIRED\") {\n warnAuth(\"access token verification failed\", { code });\n }\n return null;\n }\n}\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n return verify(token);\n}\n\nexport async function verifyAccessTokenString(\n token: string\n): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n return verify(token);\n}\n","import type { JWTPayload } from \"jose\";\nimport type { AccessTokenClaims } from \"./types.js\";\n\nexport function isAccessTokenClaims(\n payload: JWTPayload\n): payload is AccessTokenClaims {\n return (\n payload.token_type === \"access\" &&\n typeof payload.sub === \"string\" &&\n typeof payload.nickname === \"string\" &&\n typeof payload.profile_image_url === \"string\" &&\n (payload.role === \"user\" || payload.role === \"admin\") &&\n Array.isArray(payload.approved_clients) &&\n payload.approved_clients.every((clientId) => typeof clientId === \"string\")\n );\n}\n","/**\n * 진단 로그.\n *\n * 이 라이브러리는 오랫동안 실패를 전부 `null` 하나로만 알렸다. 2026-07-19 에\n * auth 서버 앞의 Cloudflare Bot Fight Mode 가 프록시의 갱신 요청을 403\n * (`cf-mitigated: challenge`) 으로 막았을 때, 남은 흔적이 하나도 없어서\n * 원인을 찾는 데 반나절이 걸렸다. 앱 쪽에 임시 진단 코드를 심고 프로덕션에\n * 두 번 배포한 뒤에야 응답 코드를 볼 수 있었다.\n *\n * 그래서 실패에는 최소한의 사실을 남긴다.\n *\n * **토큰 값은 절대 남기지 않는다.** JWT 는 base64url 이라 \"접두 8자\" 같은\n * 안전해 보이는 일부만으로도 헤더와 payload 앞부분이 그대로 복원된다.\n * `sub`, `nickname`, `approved_clients` 가 거기 들어 있다. 남길 수 있는 것은\n * 쿠키 **이름**, HTTP 상태 코드, 에러 코드처럼 값이 아닌 사실뿐이다.\n */\n\nconst PREFIX = \"[lemon-auth]\";\n\nexport function warnAuth(\n event: string,\n detail?: Record<string, unknown>\n): void {\n if (detail === undefined) {\n console.warn(`${PREFIX} ${event}`);\n return;\n }\n console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));\n}\n\n/**\n * 에러에서 값 노출 없이 식별자만 뽑는다.\n *\n * jose 는 `ERR_JWKS_NO_MATCHING_KEY`(키 로테이션), `ERR_JWT_EXPIRED`(정상 만료),\n * `ERR_JWS_SIGNATURE_VERIFICATION_FAILED`(위조 또는 설정 오류) 처럼 원인이\n * 전혀 다른 실패를 구분 가능한 `code` 로 준다. 이걸 뭉개면 JWKS 장애와\n * 위조 토큰이 같은 증상으로 보인다.\n *\n * undici 의 네트워크 실패는 `cause.code` 에 `ENOTFOUND` 같은 값이 들어간다.\n */\nexport function errorCode(error: unknown): string {\n if (typeof error === \"object\" && error !== null) {\n const { code, cause } = error as { code?: unknown; cause?: unknown };\n if (typeof code === \"string\") return code;\n if (typeof cause === \"object\" && cause !== null) {\n const causeCode = (cause as { code?: unknown }).code;\n if (typeof causeCode === \"string\") return causeCode;\n }\n }\n if (error instanceof Error) return error.name;\n return \"unknown\";\n}\n","import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(\n claims: NonNullable<Awaited<ReturnType<typeof verifyAccessToken>>>\n): LemonUser {\n return {\n uid: claims.sub,\n nickname: claims.nickname,\n profileImageUrl: claims.profile_image_url,\n role: claims.role,\n approvedClients: claims.approved_clients,\n };\n}\n\nexport const getUser = cache(async (): Promise<LemonUser | null> => {\n const claims = await verifyAccessToken();\n if (!claims) return null;\n return claimsToUser(claims);\n});\n\nexport interface GetSessionOptions {\n clientId?: string;\n}\n\nconst getSessionByClientId = cache(\n async (clientId?: string): Promise<LemonSession> => {\n const user = await getUser();\n if (!user) return { type: \"none\" };\n if (!isApprovedClient(user.approvedClients, clientId)) {\n return { type: \"unapproved\", user };\n }\n return { type: \"authenticated\", user };\n }\n);\n\nexport async function getSession(\n options: GetSessionOptions = {}\n): Promise<LemonSession> {\n return getSessionByClientId(options.clientId);\n}\n\nexport async function requireAuth(redirectTo = \"/\"): Promise<LemonUser> {\n const user = await getUser();\n if (user) return user;\n redirect(redirectTo);\n}\n\nexport interface RequireClientOptions {\n loginRedirectTo?: string;\n unapprovedRedirectTo?: string;\n}\n\nexport async function requireClient(\n clientId: string,\n optionsOrRedirectTo: string | RequireClientOptions = \"/\"\n): Promise<LemonUser> {\n const options =\n typeof optionsOrRedirectTo === \"string\"\n ? { loginRedirectTo: optionsOrRedirectTo }\n : optionsOrRedirectTo;\n const session = await getSession({ clientId });\n if (session.type === \"none\") {\n redirect(options.loginRedirectTo ?? \"/\");\n }\n if (session.type === \"unapproved\") {\n redirect(\n options.unapprovedRedirectTo ??\n `${AUTH_SERVER_URL}/error?code=FORBIDDEN`\n );\n }\n return session.user;\n}\n","import { warnAuth } from \"./log.js\";\n\n/** fail-open 경고를 요청마다 찍지 않기 위한 플래그. */\nlet warnedMissingClientId = false;\n\nexport function isApprovedClient(\n approvedClients: string[],\n clientId?: string\n): boolean {\n if (!clientId) {\n // clientId 를 안 넘기면 승인 검사가 통째로 열린다(fail-open). 이 라이브러리를\n // clientId 없이 쓰는 앱도 있으므로 동작은 유지하지만, 환경변수가 배포에서\n // 빠져서 이렇게 된 경우와 구분이 안 된다. 후자는 인증이 사실상 없는 상태라\n // 조용히 넘어가면 안 된다.\n if (!warnedMissingClientId) {\n warnedMissingClientId = true;\n warnAuth(\n \"clientId not provided — approval check is disabled (all users pass). \" +\n \"설정 누락이 아닌지 확인할 것.\"\n );\n }\n return true;\n }\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/server/user.ts","../../src/server/verify.ts","../../src/claims.ts","../../src/log.ts"],"sourcesContent":["import { cache } from \"react\";\nimport { redirect } from \"next/navigation\";\nimport type { AccessTokenClaims, LemonSession, LemonUser } from \"../types.js\";\nimport { AUTH_SERVER_URL } from \"../constants.js\";\nimport { verifyAccessToken } from \"./verify.js\";\n\nfunction claimsToUser(claims: AccessTokenClaims): LemonUser {\n return {\n uid: claims.sub,\n nickname: claims.nickname,\n profileImageUrl: claims.profile_image_url,\n role: claims.role,\n approvedClients: claims.approved_clients,\n };\n}\n\nexport const getUser = cache(async (): Promise<LemonUser | null> => {\n const claims = await verifyAccessToken();\n if (!claims) return null;\n return claimsToUser(claims);\n});\n\nconst getSessionByClientId = cache(\n async (clientId: string): Promise<LemonSession> => {\n const user = await getUser();\n if (!user) return { type: \"none\" };\n if (!isApprovedClient(user.approvedClients, clientId)) {\n return { type: \"unapproved\", user };\n }\n return { type: \"authenticated\", user };\n }\n);\n\nexport async function getSession({\n clientId,\n}: {\n clientId: string;\n}): Promise<LemonSession> {\n return getSessionByClientId(clientId);\n}\n\nexport async function requireClient(\n clientId: string,\n {\n loginRedirectTo = \"/\",\n unapprovedRedirectTo = `${AUTH_SERVER_URL}/error?code=FORBIDDEN`,\n }: { loginRedirectTo?: string; unapprovedRedirectTo?: string } = {}\n): Promise<LemonUser> {\n const session = await getSession({ clientId });\n if (session.type === \"none\") redirect(loginRedirectTo);\n if (session.type === \"unapproved\") redirect(unapprovedRedirectTo);\n return session.user;\n}\n\n// clientId 가 비어 있으면(환경변수 누락) 아무도 승인되지 않는다. \"*\" 는 mock user 용이다.\nfunction isApprovedClient(approvedClients: string[], clientId: string): boolean {\n if (!clientId) return false;\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n","import { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { cookies } from \"next/headers\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { JWKS_URL, AUTH_SERVER_URL, ACCESS_TOKEN_COOKIE } from \"../constants.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport async function verifyAccessToken(): Promise<AccessTokenClaims | null> {\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) return mockClaims;\n\n const cookieStore = await cookies();\n const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;\n if (!token) return null;\n\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n // 발급자(자택 k3s)와 검증자(Vercel)의 시계 어긋남 허용\n clockTolerance: 10,\n });\n if (isAccessTokenClaims(payload)) return payload;\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료는 흔한 경로라 남기지 않는다. 키 로테이션·JWKS 장애와 위조를 구분하려고 code 를 남긴다.\n const code = errorCode(error);\n if (code !== \"ERR_JWT_EXPIRED\") {\n warnAuth(\"access token verification failed\", { code });\n }\n return null;\n }\n}\n","import type { JWTPayload } from \"jose\";\nimport type { AccessTokenClaims } from \"./types.js\";\n\nexport function isAccessTokenClaims(\n payload: JWTPayload\n): payload is AccessTokenClaims {\n return (\n payload.token_type === \"access\" &&\n typeof payload.sub === \"string\" &&\n typeof payload.nickname === \"string\" &&\n typeof payload.profile_image_url === \"string\" &&\n (payload.role === \"user\" || payload.role === \"admin\") &&\n Array.isArray(payload.approved_clients) &&\n payload.approved_clients.every((clientId) => typeof clientId === \"string\")\n );\n}\n","/** 진단 로그. 토큰 값은 일부라도 절대 남기지 않는다 — JWT 는 앞부분만으로 payload 가 복원된다. */\n\nconst PREFIX = \"[lemon-auth]\";\n\nexport function warnAuth(\n event: string,\n detail?: Record<string, unknown>\n): void {\n if (detail === undefined) {\n console.warn(`${PREFIX} ${event}`);\n return;\n }\n console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));\n}\n\n/** 에러에서 값 없이 식별 코드만 뽑는다 (jose 의 `code`, 네트워크 실패의 `cause.code`). */\nexport function errorCode(error: unknown): string {\n if (typeof error === \"object\" && error !== null) {\n const { code, cause } = error as { code?: unknown; cause?: unknown };\n if (typeof code === \"string\") return code;\n if (typeof cause === \"object\" && cause !== null) {\n const causeCode = (cause as { code?: unknown }).code;\n if (typeof causeCode === \"string\") return causeCode;\n }\n }\n if (error instanceof Error) return error.name;\n return \"unknown\";\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,24 @@
1
+ interface LemonUser {
2
+ uid: string;
3
+ nickname: string;
4
+ profileImageUrl: string;
5
+ role: "user" | "admin";
6
+ approvedClients: string[];
7
+ }
8
+ type LemonSession = {
9
+ type: "none";
10
+ } | {
11
+ type: "unapproved";
12
+ user: LemonUser;
13
+ } | {
14
+ type: "authenticated";
15
+ user: LemonUser;
16
+ };
17
+
18
+ declare function loginUrl(redirectUrl: string): string;
19
+ declare function profileUrl(redirectUrl?: string): string;
20
+ /** 쿠키는 httpOnly 라 삭제는 응답의 Set-Cookie 가 한다. */
21
+ declare function logout(): Promise<boolean>;
22
+ declare function refreshToken(): Promise<boolean>;
23
+
24
+ export { type LemonSession as L, type LemonUser as a, logout as b, loginUrl as l, profileUrl as p, refreshToken as r };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemondouble/lemon-auth",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/LemonDouble/lemon-auth"
@@ -1,34 +0,0 @@
1
- import { JWTPayload } from 'jose';
2
-
3
- interface AccessTokenClaims extends JWTPayload {
4
- token_type: "access";
5
- sub: string;
6
- nickname: string;
7
- profile_image_url: string;
8
- role: "user" | "admin";
9
- approved_clients: string[];
10
- }
11
- interface LemonUser {
12
- uid: string;
13
- nickname: string;
14
- profileImageUrl: string;
15
- role: "user" | "admin";
16
- approvedClients: string[];
17
- }
18
- type LemonSession = {
19
- type: "none";
20
- } | {
21
- type: "unapproved";
22
- user: LemonUser;
23
- } | {
24
- type: "authenticated";
25
- user: LemonUser;
26
- };
27
- interface UserProfile {
28
- uid: string;
29
- nickname: string;
30
- profile_image_url: string;
31
- role: "user" | "admin";
32
- }
33
-
34
- export type { AccessTokenClaims as A, LemonSession as L, UserProfile as U, LemonUser as a };