@lemondouble/lemon-auth 0.10.0 → 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.
- package/LICENSE +21 -0
- package/README.md +75 -463
- package/dist/{chunk-ICPDIWYK.js → chunk-TKMZEMW7.js} +5 -11
- package/dist/chunk-TKMZEMW7.js.map +1 -0
- package/dist/client/index.d.ts +3 -27
- package/dist/client/index.js +1 -53
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.js +225 -16
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/{url-qhLR3uNu.d.ts → url-O6F1o0px.d.ts} +1 -4
- package/package.json +1 -1
- package/dist/chunk-ICPDIWYK.js.map +0 -1
package/dist/proxy/index.js.map
CHANGED
|
@@ -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 * 갱신 주체는 RESTORE_PATH 에 마운트된 <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\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\n/** 앱이 <SessionRestore /> 를 마운트해야 하는 경로. */\nconst RESTORE_PATH = \"/auth/restore\";\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 // 복구 경로 자신은 항상 통과. 여기서 복구 경로로 보내면 무한 루프다.\n if (pathname === RESTORE_PATH) {\n return NextResponse.next();\n }\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 을 리다이렉트하면 액션 프로토콜이 깨진다.\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(RESTORE_PATH, 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":";;;;;;;;AAAA,SAAsB,oBAAoB;AA2B1C,IAAM,yBAAyB;AAE/B,IAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEO,IAAM,oBAAoB,CAAC,QAAQ;AAG1C,IAAM,eAAe;AAYd,SAAS,gBAAgB,UAA4B,CAAC,GAAG;AAC9D,QAAM;AAAA,IACJ,cAAc,CAAC;AAAA,IACf,WAAW;AAAA,IACX;AAAA,EACF,IAAI;AAEJ,SAAO,eAAe,MAAM,SAA6C;AACvE,UAAM,EAAE,SAAS,IAAI,QAAQ;AAG7B,QAAI,aAAa,cAAc;AAC7B,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,QAAI,aAAa,UAAU,iBAAiB,GAAG;AAC7C,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,QAAI,kBAAkB,GAAG;AACvB,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,UAAM,cAAc,QAAQ,QAAQ,IAAI,mBAAmB,GAAG;AAC9D,QAAI,eAAe,aAAa,WAAW,GAAG;AAC5C,aAAO,aAAa,KAAK;AAAA,IAC3B;AAIA,UAAM,WAAW,aAAa,UAAU,WAAW;AAEnD,QAAI,aAAa,UAAU,QAAQ,GAAG;AAGpC,UAAI,UAAU;AACZ,eAAO,aAAa,KAAK;AAAA,MAC3B;AACA,aAAO,aAAa;AAAA,QAClB,EAAE,MAAM,eAAe;AAAA,QACvB,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAIA,QAAI,QAAQ,WAAW,OAAO;AAC5B,aAAO,aAAa,KAAK;AAAA,IAC3B;AAMA,QAAI,QAAQ,QAAQ,IAAI,oBAAoB,GAAG,OAAO;AACpD,YAAM,UAAU,IAAI,IAAI,cAAc,QAAQ,GAAG;AACjD,cAAQ,aAAa,IAAI,QAAQ,WAAW,QAAQ,QAAQ,MAAM;AAClE,aAAO,aAAa,SAAS,OAAO;AAAA,IACtC;AAEA,QAAI,UAAU;AACZ,aAAO,aAAa,KAAK;AAAA,IAC3B;AAEA,WAAO,gBAAgB,SAAS,gBAAgB;AAAA,EAClD;AACF;AAQA,SAAS,aAAa,OAAwB;AAC5C,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,UAAM,UAAU,KAAK;AAAA,MACnB,OAAO,KAAK,MAAM,CAAC,GAAG,WAAW,EAAE,SAAS;AAAA,IAC9C;AACA,WACE,OAAO,QAAQ,QAAQ,YACvB,QAAQ,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AAAA,EAElD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,UAAkB,aAAgC;AACtE,SAAO,YAAY,KAAK,CAAC,MAAM;AAC7B,QAAI,EAAE,SAAS,GAAG,GAAG;AACnB,aAAO,SAAS,WAAW,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,IAC3C;AACA,WAAO,aAAa;AAAA,EACtB,CAAC;AACH;AAEA,SAAS,gBACP,SACA,kBACc;AACd,QAAM,cACJ,OAAO,qBAAqB,aACxB,iBAAiB,OAAO,IACxB;AAEN,MAAI,aAAa;AACf,UAAM,MAAM,GAAG,SAAS,iBAAiB,mBAAmB,WAAW,CAAC;AACxE,WAAO,aAAa,SAAS,IAAI,IAAI,KAAK,QAAQ,GAAG,CAAC;AAAA,EACxD;AACA,SAAO,aAAa,SAAS,IAAI,IAAI,KAAK,QAAQ,GAAG,CAAC;AACxD;","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"]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { L as LemonSession, a as LemonUser } from '../url-
|
|
2
|
-
export { l as loginUrl, p as profileUrl } from '../url-
|
|
1
|
+
import { L as LemonSession, a as LemonUser } from '../url-O6F1o0px.js';
|
|
2
|
+
export { l as loginUrl, p as profileUrl } from '../url-O6F1o0px.js';
|
|
3
3
|
|
|
4
4
|
declare const getUser: () => Promise<LemonUser | null>;
|
|
5
5
|
declare function getSession({ clientId, }: {
|
package/dist/server/index.js
CHANGED
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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","
|
|
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":[]}
|
|
@@ -17,10 +17,7 @@ type LemonSession = {
|
|
|
17
17
|
|
|
18
18
|
declare function loginUrl(redirectUrl: string): string;
|
|
19
19
|
declare function profileUrl(redirectUrl?: string): string;
|
|
20
|
-
/**
|
|
21
|
-
* 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly
|
|
22
|
-
* 라서 JS 로는 지울 수 없다.
|
|
23
|
-
*/
|
|
20
|
+
/** 쿠키는 httpOnly 라 삭제는 응답의 Set-Cookie 가 한다. */
|
|
24
21
|
declare function logout(): Promise<boolean>;
|
|
25
22
|
declare function refreshToken(): Promise<boolean>;
|
|
26
23
|
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client/url.ts"],"sourcesContent":["import {\n LOGIN_URL,\n LOGOUT_URL,\n PROFILE_PAGE_URL,\n REFRESH_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\n\nexport function loginUrl(redirectUrl: string): string {\n if (isMockAuthEnabled()) return redirectUrl;\n assertValidLoginRedirectUrl(redirectUrl);\n return `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n}\n\nexport function profileUrl(redirectUrl?: string): string {\n if (isMockAuthEnabled()) return redirectUrl ?? \"/\";\n if (redirectUrl) {\n return `${PROFILE_PAGE_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;\n }\n return PROFILE_PAGE_URL;\n}\n\n/**\n * 로그아웃. 쿠키 삭제는 응답의 Set-Cookie 가 한다 — 세션 쿠키는 httpOnly\n * 라서 JS 로는 지울 수 없다.\n */\nexport async function logout(): Promise<boolean> {\n if (isMockAuthEnabled()) return true;\n\n try {\n const res = await fetch(LOGOUT_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n\nexport type RefreshSessionResult = \"ok\" | \"unauthorized\" | \"unavailable\";\n\n/**\n * 세션 갱신. 쿠키 반영은 응답의 Set-Cookie 가 한다.\n *\n * \"unauthorized\" 는 refresh token 이 죽은 것이고, 서버가 그 응답에서 세션\n * 쿠키를 함께 지워준다. \"unavailable\" 은 서버 · 네트워크 사정이므로 세션이\n * 죽었다고 판단하면 안 된다.\n */\nexport async function refreshSession(): Promise<RefreshSessionResult> {\n if (isMockAuthEnabled()) return \"ok\";\n\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n credentials: \"include\",\n });\n if (res.ok) return \"ok\";\n if (res.status === 401 || res.status === 403) return \"unauthorized\";\n return \"unavailable\";\n } catch {\n return \"unavailable\";\n }\n}\n\nexport async function refreshToken(): Promise<boolean> {\n return (await refreshSession()) === \"ok\";\n}\n\nfunction assertValidLoginRedirectUrl(redirectUrl: string): void {\n let url: URL;\n try {\n url = new URL(redirectUrl);\n } catch {\n throw new Error(\"loginUrl redirectUrl must be a valid absolute URL\");\n }\n\n if (url.protocol !== \"https:\") {\n throw new Error(\"loginUrl redirectUrl must use https\");\n }\n\n if (!isAllowedLoginRedirectHost(url.hostname)) {\n throw new Error(\n \"loginUrl redirectUrl host must be lemondouble.com or a subdomain of lemondouble.com\"\n );\n }\n}\n\nfunction isAllowedLoginRedirectHost(hostname: string): boolean {\n const normalized = hostname.toLowerCase();\n return (\n normalized === \"lemondouble.com\" ||\n normalized.endsWith(\".lemondouble.com\")\n );\n}\n"],"mappings":";;;;;;;;;AAQO,SAAS,SAAS,aAA6B;AACpD,MAAI,kBAAkB,EAAG,QAAO;AAChC,8BAA4B,WAAW;AACvC,SAAO,GAAG,SAAS,iBAAiB,mBAAmB,WAAW,CAAC;AACrE;AAEO,SAAS,WAAW,aAA8B;AACvD,MAAI,kBAAkB,EAAG,QAAO,eAAe;AAC/C,MAAI,aAAa;AACf,WAAO,GAAG,gBAAgB,iBAAiB,mBAAmB,WAAW,CAAC;AAAA,EAC5E;AACA,SAAO;AACT;AAMA,eAAsB,SAA2B;AAC/C,MAAI,kBAAkB,EAAG,QAAO;AAEhC,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,YAAY;AAAA,MAClC,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,WAAO,IAAI;AAAA,EACb,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWA,eAAsB,iBAAgD;AACpE,MAAI,kBAAkB,EAAG,QAAO;AAEhC,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,aAAa;AAAA,MACnC,QAAQ;AAAA,MACR,aAAa;AAAA,IACf,CAAC;AACD,QAAI,IAAI,GAAI,QAAO;AACnB,QAAI,IAAI,WAAW,OAAO,IAAI,WAAW,IAAK,QAAO;AACrD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,eAAiC;AACrD,SAAQ,MAAM,eAAe,MAAO;AACtC;AAEA,SAAS,4BAA4B,aAA2B;AAC9D,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,WAAW;AAAA,EAC3B,QAAQ;AACN,UAAM,IAAI,MAAM,mDAAmD;AAAA,EACrE;AAEA,MAAI,IAAI,aAAa,UAAU;AAC7B,UAAM,IAAI,MAAM,qCAAqC;AAAA,EACvD;AAEA,MAAI,CAAC,2BAA2B,IAAI,QAAQ,GAAG;AAC7C,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,2BAA2B,UAA2B;AAC7D,QAAM,aAAa,SAAS,YAAY;AACxC,SACE,eAAe,qBACf,WAAW,SAAS,kBAAkB;AAE1C;","names":[]}
|