@lemondouble/lemon-auth 0.7.0 → 0.7.2
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/dist/chunk-K4U4PHX6.js +49 -0
- package/dist/chunk-K4U4PHX6.js.map +1 -0
- package/dist/proxy/index.js +59 -16
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.js +28 -18
- package/dist/server/index.js.map +1 -1
- package/package.json +9 -7
- package/dist/chunk-6W24RWSJ.js +0 -16
- package/dist/chunk-6W24RWSJ.js.map +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/claims.ts
|
|
2
|
+
function isAccessTokenClaims(payload) {
|
|
3
|
+
return payload.token_type === "access" && typeof payload.sub === "string" && typeof payload.nickname === "string" && typeof payload.profile_image_url === "string" && (payload.role === "user" || payload.role === "admin") && Array.isArray(payload.approved_clients) && payload.approved_clients.every((clientId) => typeof clientId === "string");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// src/log.ts
|
|
7
|
+
var PREFIX = "[lemon-auth]";
|
|
8
|
+
function warnAuth(event, detail) {
|
|
9
|
+
if (detail === void 0) {
|
|
10
|
+
console.warn(`${PREFIX} ${event}`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));
|
|
14
|
+
}
|
|
15
|
+
function errorCode(error) {
|
|
16
|
+
if (typeof error === "object" && error !== null) {
|
|
17
|
+
const { code, cause } = error;
|
|
18
|
+
if (typeof code === "string") return code;
|
|
19
|
+
if (typeof cause === "object" && cause !== null) {
|
|
20
|
+
const causeCode = cause.code;
|
|
21
|
+
if (typeof causeCode === "string") return causeCode;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof Error) return error.name;
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/approval.ts
|
|
29
|
+
var warnedMissingClientId = false;
|
|
30
|
+
function isApprovedClient(approvedClients, clientId) {
|
|
31
|
+
if (!clientId) {
|
|
32
|
+
if (!warnedMissingClientId) {
|
|
33
|
+
warnedMissingClientId = true;
|
|
34
|
+
warnAuth(
|
|
35
|
+
"clientId not provided \u2014 approval check is disabled (all users pass). \uC124\uC815 \uB204\uB77D\uC774 \uC544\uB2CC\uC9C0 \uD655\uC778\uD560 \uAC83."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return approvedClients.includes(clientId) || approvedClients.includes("*");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
isAccessTokenClaims,
|
|
45
|
+
warnAuth,
|
|
46
|
+
errorCode,
|
|
47
|
+
isApprovedClient
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=chunk-K4U4PHX6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/claims.ts","../src/log.ts","../src/approval.ts"],"sourcesContent":["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 { 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":";AAGO,SAAS,oBACd,SAC8B;AAC9B,SACE,QAAQ,eAAe,YACvB,OAAO,QAAQ,QAAQ,YACvB,OAAO,QAAQ,aAAa,YAC5B,OAAO,QAAQ,sBAAsB,aACpC,QAAQ,SAAS,UAAU,QAAQ,SAAS,YAC7C,MAAM,QAAQ,QAAQ,gBAAgB,KACtC,QAAQ,iBAAiB,MAAM,CAAC,aAAa,OAAO,aAAa,QAAQ;AAE7E;;;ACEA,IAAM,SAAS;AAER,SAAS,SACd,OACA,QACM;AACN,MAAI,WAAW,QAAW;AACxB,YAAQ,KAAK,GAAG,MAAM,IAAI,KAAK,EAAE;AACjC;AAAA,EACF;AACA,UAAQ,KAAK,GAAG,MAAM,IAAI,KAAK,IAAI,KAAK,UAAU,MAAM,CAAC;AAC3D;AAYO,SAAS,UAAU,OAAwB;AAChD,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,UAAM,EAAE,MAAM,MAAM,IAAI;AACxB,QAAI,OAAO,SAAS,SAAU,QAAO;AACrC,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,YAAM,YAAa,MAA6B;AAChD,UAAI,OAAO,cAAc,SAAU,QAAO;AAAA,IAC5C;AAAA,EACF;AACA,MAAI,iBAAiB,MAAO,QAAO,MAAM;AACzC,SAAO;AACT;;;AChDA,IAAI,wBAAwB;AAErB,SAAS,iBACd,iBACA,UACS;AACT,MAAI,CAAC,UAAU;AAKb,QAAI,CAAC,uBAAuB;AAC1B,8BAAwB;AACxB;AAAA,QACE;AAAA,MAEF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO,gBAAgB,SAAS,QAAQ,KAAK,gBAAgB,SAAS,GAAG;AAC3E;","names":[]}
|
package/dist/proxy/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
errorCode,
|
|
2
3
|
isAccessTokenClaims,
|
|
3
|
-
isApprovedClient
|
|
4
|
-
|
|
4
|
+
isApprovedClient,
|
|
5
|
+
warnAuth
|
|
6
|
+
} from "../chunk-K4U4PHX6.js";
|
|
5
7
|
import {
|
|
6
8
|
ACCESS_TOKEN_COOKIE,
|
|
7
9
|
AUTH_SERVER_URL,
|
|
@@ -94,10 +96,10 @@ function createAuthProxy(options = {}) {
|
|
|
94
96
|
if (result) {
|
|
95
97
|
const claims = await verifyToken(result.newAccessToken);
|
|
96
98
|
if (claims) {
|
|
97
|
-
const response =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
const response = nextWithRefreshedCookies(
|
|
100
|
+
request,
|
|
101
|
+
result.setCookieHeaders
|
|
102
|
+
);
|
|
101
103
|
return handleVerifiedClaims(
|
|
102
104
|
claims,
|
|
103
105
|
request,
|
|
@@ -141,15 +143,36 @@ async function maybeRefreshAndContinue(request) {
|
|
|
141
143
|
if (refreshToken) {
|
|
142
144
|
const result = await tryRefresh(refreshToken, deviceId);
|
|
143
145
|
if (result) {
|
|
144
|
-
|
|
145
|
-
for (const header of result.setCookieHeaders) {
|
|
146
|
-
response.headers.append("Set-Cookie", header);
|
|
147
|
-
}
|
|
148
|
-
return response;
|
|
146
|
+
return nextWithRefreshedCookies(request, result.setCookieHeaders);
|
|
149
147
|
}
|
|
148
|
+
warnAuth("refresh failed on public path, continuing anonymously", {
|
|
149
|
+
path: request.nextUrl.pathname
|
|
150
|
+
});
|
|
150
151
|
}
|
|
151
152
|
return NextResponse.next();
|
|
152
153
|
}
|
|
154
|
+
function nextWithRefreshedCookies(request, setCookieHeaders) {
|
|
155
|
+
const merged = /* @__PURE__ */ new Map();
|
|
156
|
+
for (const { name, value } of request.cookies.getAll()) {
|
|
157
|
+
merged.set(name, value);
|
|
158
|
+
}
|
|
159
|
+
for (const header of setCookieHeaders) {
|
|
160
|
+
const pair = header.split(";")[0] ?? "";
|
|
161
|
+
const separator = pair.indexOf("=");
|
|
162
|
+
if (separator <= 0) continue;
|
|
163
|
+
merged.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());
|
|
164
|
+
}
|
|
165
|
+
const requestHeaders = new Headers(request.headers);
|
|
166
|
+
requestHeaders.set(
|
|
167
|
+
"cookie",
|
|
168
|
+
Array.from(merged, ([name, value]) => `${name}=${value}`).join("; ")
|
|
169
|
+
);
|
|
170
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
171
|
+
for (const header of setCookieHeaders) {
|
|
172
|
+
response.headers.append("Set-Cookie", header);
|
|
173
|
+
}
|
|
174
|
+
return response;
|
|
175
|
+
}
|
|
153
176
|
function isTokenFresh(token) {
|
|
154
177
|
try {
|
|
155
178
|
const parts = token.split(".");
|
|
@@ -167,8 +190,14 @@ async function verifyToken(token) {
|
|
|
167
190
|
const { payload } = await jwtVerify(token, jwks, {
|
|
168
191
|
issuer: AUTH_SERVER_URL
|
|
169
192
|
});
|
|
170
|
-
|
|
171
|
-
|
|
193
|
+
if (isAccessTokenClaims(payload)) return payload;
|
|
194
|
+
warnAuth("access token claims shape mismatch");
|
|
195
|
+
return null;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const code = errorCode(error);
|
|
198
|
+
if (code !== "ERR_JWT_EXPIRED") {
|
|
199
|
+
warnAuth("access token verification failed", { code });
|
|
200
|
+
}
|
|
172
201
|
return null;
|
|
173
202
|
}
|
|
174
203
|
}
|
|
@@ -178,12 +207,26 @@ async function tryRefresh(refreshToken, deviceId) {
|
|
|
178
207
|
method: "POST",
|
|
179
208
|
headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) }
|
|
180
209
|
});
|
|
181
|
-
if (!res.ok)
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
warnAuth("token refresh rejected", {
|
|
212
|
+
status: res.status,
|
|
213
|
+
server: res.headers.get("server"),
|
|
214
|
+
cfRay: res.headers.get("cf-ray"),
|
|
215
|
+
cfMitigated: res.headers.get("cf-mitigated")
|
|
216
|
+
});
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
182
219
|
const setCookieHeaders = res.headers.getSetCookie();
|
|
183
220
|
const newAccessToken = extractAccessToken(setCookieHeaders);
|
|
184
|
-
if (!newAccessToken)
|
|
221
|
+
if (!newAccessToken) {
|
|
222
|
+
warnAuth("refresh succeeded but no access token cookie", {
|
|
223
|
+
cookieNames: setCookieHeaders.map((h) => h.split("=")[0])
|
|
224
|
+
});
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
185
227
|
return { newAccessToken, setCookieHeaders };
|
|
186
|
-
} catch {
|
|
228
|
+
} catch (error) {
|
|
229
|
+
warnAuth("token refresh request failed", { code: errorCode(error) });
|
|
187
230
|
return null;
|
|
188
231
|
}
|
|
189
232
|
}
|
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 DEVICE_ID_COOKIE,\n AUTH_SERVER_URL,\n REFRESH_URL,\n JWKS_URL,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\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\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\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n publicPaths?: string[];\n bypassPaths?: string[];\n apiPaths?: string[];\n clientId?: string;\n loginRedirectUrl?: LoginRedirectUrl;\n unapprovedRedirectUrl?: LoginRedirectUrl;\n onAuthSuccess?: (\n claims: AccessTokenClaims,\n request: NextRequest,\n response: NextResponse\n ) => Promise<NextResponse> | NextResponse;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n clientId,\n loginRedirectUrl,\n unapprovedRedirectUrl,\n onAuthSuccess,\n } = options;\n const effectiveBypassPaths = [\n ...DEFAULT_AUTH_BYPASS_PATHS,\n ...bypassPaths,\n ];\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n if (isPublicPath(pathname, effectiveBypassPaths)) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, publicPaths)) {\n return maybeRefreshAndContinue(request);\n }\n\n const isApiRequest = isPublicPath(pathname, apiPaths);\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) {\n return handleVerifiedClaims(\n mockClaims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n const claims = await verifyToken(accessToken);\n if (claims) {\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n const claims = await verifyToken(result.newAccessToken);\n if (claims) {\n const response = NextResponse.next();\n for (const header of result.setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n response\n );\n }\n }\n }\n\n return isApiRequest\n ? unauthorizedJson()\n : redirectToLogin(request, loginRedirectUrl);\n };\n}\n\nasync function handleVerifiedClaims(\n claims: AccessTokenClaims,\n request: NextRequest,\n isApiRequest: boolean,\n clientId: string | undefined,\n unapprovedRedirectUrl: LoginRedirectUrl | undefined,\n onAuthSuccess: AuthProxyOptions[\"onAuthSuccess\"],\n response: NextResponse\n): Promise<NextResponse> {\n if (!isApprovedClient(claims.approved_clients, clientId)) {\n return isApiRequest\n ? forbiddenJson()\n : redirectToUnapproved(request, unapprovedRedirectUrl);\n }\n\n return onAuthSuccess\n ? await onAuthSuccess(claims, request, response)\n : response;\n}\n\nfunction unauthorizedJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n}\n\nfunction forbiddenJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.FORBIDDEN },\n { status: 403 }\n );\n}\n\nasync function maybeRefreshAndContinue(\n request: NextRequest\n): Promise<NextResponse> {\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n const response = NextResponse.next();\n for (const header of result.setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return response;\n }\n }\n\n return NextResponse.next();\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 );\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\nasync function verifyToken(\n token: string\n): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n return isAccessTokenClaims(payload) ? payload : null;\n } catch {\n return null;\n }\n}\n\ninterface RefreshSuccess {\n newAccessToken: string;\n setCookieHeaders: string[];\n}\n\nasync function tryRefresh(\n refreshToken: string,\n deviceId?: string\n): Promise<RefreshSuccess | null> {\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) },\n });\n\n if (!res.ok) return null;\n\n const setCookieHeaders = res.headers.getSetCookie();\n const newAccessToken = extractAccessToken(setCookieHeaders);\n if (!newAccessToken) return null;\n\n return { newAccessToken, setCookieHeaders };\n } catch {\n return null;\n }\n}\n\nfunction buildRefreshCookieHeader(\n refreshToken: string,\n deviceId?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];\n if (deviceId) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);\n }\n return cookies.join(\"; \");\n}\n\nfunction extractAccessToken(setCookieHeaders: string[]): string | null {\n for (const header of setCookieHeaders) {\n const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));\n if (match) return match[1];\n }\n return null;\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\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, loginRedirectUrl);\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\nfunction redirectToUnapproved(\n request: NextRequest,\n unapprovedRedirectUrl?: LoginRedirectUrl\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);\n if (redirectUrl) {\n return NextResponse.redirect(new URL(redirectUrl, request.url));\n }\n const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);\n errorUrl.searchParams.set(\"code\", PROXY_AUTH_ERROR.FORBIDDEN);\n errorUrl.searchParams.set(\"from\", request.url);\n return NextResponse.redirect(errorUrl);\n}\n\nfunction resolveLoginRedirectUrl(\n request: NextRequest,\n loginRedirectUrl?: LoginRedirectUrl\n): string | undefined {\n if (typeof loginRedirectUrl === \"function\") {\n return loginRedirectUrl(request);\n }\n return loginRedirectUrl;\n}\n\nexport type { AccessTokenClaims } from \"../types.js\";\nexport type { LemonUser } from \"../types.js\";\n"],"mappings":"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","names":[]}
|
|
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 DEVICE_ID_COOKIE,\n AUTH_SERVER_URL,\n REFRESH_URL,\n JWKS_URL,\n LOGIN_URL,\n} from \"../constants.js\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { AccessTokenClaims } from \"../types.js\";\nimport { isAccessTokenClaims } from \"../claims.js\";\nimport { isApprovedClient } from \"../approval.js\";\nimport { getMockAccessTokenClaims } from \"../mock.js\";\nimport { warnAuth, errorCode } from \"../log.js\";\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\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\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\nexport type LoginRedirectUrl = string | ((request: NextRequest) => string);\n\nexport interface AuthProxyOptions {\n publicPaths?: string[];\n bypassPaths?: string[];\n apiPaths?: string[];\n clientId?: string;\n loginRedirectUrl?: LoginRedirectUrl;\n unapprovedRedirectUrl?: LoginRedirectUrl;\n onAuthSuccess?: (\n claims: AccessTokenClaims,\n request: NextRequest,\n response: NextResponse\n ) => Promise<NextResponse> | NextResponse;\n}\n\nexport function createAuthProxy(options: AuthProxyOptions = {}) {\n const {\n publicPaths = [],\n bypassPaths = [],\n apiPaths = DEFAULT_API_PATHS,\n clientId,\n loginRedirectUrl,\n unapprovedRedirectUrl,\n onAuthSuccess,\n } = options;\n const effectiveBypassPaths = [\n ...DEFAULT_AUTH_BYPASS_PATHS,\n ...bypassPaths,\n ];\n\n return async function proxy(request: NextRequest): Promise<NextResponse> {\n const { pathname } = request.nextUrl;\n\n if (isPublicPath(pathname, effectiveBypassPaths)) {\n return NextResponse.next();\n }\n\n if (isPublicPath(pathname, publicPaths)) {\n return maybeRefreshAndContinue(request);\n }\n\n const isApiRequest = isPublicPath(pathname, apiPaths);\n const mockClaims = getMockAccessTokenClaims();\n if (mockClaims) {\n return handleVerifiedClaims(\n mockClaims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n const claims = await verifyToken(accessToken);\n if (claims) {\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n NextResponse.next()\n );\n }\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n const claims = await verifyToken(result.newAccessToken);\n if (claims) {\n const response = nextWithRefreshedCookies(\n request,\n result.setCookieHeaders\n );\n return handleVerifiedClaims(\n claims,\n request,\n isApiRequest,\n clientId,\n unapprovedRedirectUrl,\n onAuthSuccess,\n response\n );\n }\n }\n }\n\n return isApiRequest\n ? unauthorizedJson()\n : redirectToLogin(request, loginRedirectUrl);\n };\n}\n\nasync function handleVerifiedClaims(\n claims: AccessTokenClaims,\n request: NextRequest,\n isApiRequest: boolean,\n clientId: string | undefined,\n unapprovedRedirectUrl: LoginRedirectUrl | undefined,\n onAuthSuccess: AuthProxyOptions[\"onAuthSuccess\"],\n response: NextResponse\n): Promise<NextResponse> {\n if (!isApprovedClient(claims.approved_clients, clientId)) {\n return isApiRequest\n ? forbiddenJson()\n : redirectToUnapproved(request, unapprovedRedirectUrl);\n }\n\n return onAuthSuccess\n ? await onAuthSuccess(claims, request, response)\n : response;\n}\n\nfunction unauthorizedJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.UNAUTHORIZED },\n { status: 401 }\n );\n}\n\nfunction forbiddenJson(): NextResponse {\n return NextResponse.json(\n { code: PROXY_AUTH_ERROR.FORBIDDEN },\n { status: 403 }\n );\n}\n\nasync function maybeRefreshAndContinue(\n request: NextRequest\n): Promise<NextResponse> {\n const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;\n const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;\n const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;\n\n if (accessToken && isTokenFresh(accessToken)) {\n return NextResponse.next();\n }\n\n if (refreshToken) {\n const result = await tryRefresh(refreshToken, deviceId);\n if (result) {\n return nextWithRefreshedCookies(request, result.setCookieHeaders);\n }\n // 공개 경로라 요청은 그대로 통과시킨다. 다만 이 실패는 밖에서 보이지 않는다\n // — 응답이 성공했을 때와 바이트 단위로 같기 때문이다. 유일한 증상은\n // \"로그인 상태로 보여야 할 화면이 익명으로 렌더되는 것\" 이고, 그마저도\n // 클라이언트 갱신(AutoTokenRefresh)이 있으면 가려진다. 그래서 여기서\n // 한 줄이라도 남겨야 한다. tryRefresh 가 이미 사유를 찍었다.\n warnAuth(\"refresh failed on public path, continuing anonymously\", {\n path: request.nextUrl.pathname,\n });\n }\n\n return NextResponse.next();\n}\n\n/**\n * 갱신된 토큰을 이 요청의 cookie 헤더에도 반영한 응답을 만든다.\n *\n * Set-Cookie 는 브라우저에만 적용된다. 그것만 붙이면 지금 이 요청을 처리하는\n * 서버 컴포넌트는 여전히 만료된 access token 을 읽고, 갱신에 성공했는데도\n * 그 요청은 로그아웃으로 판정된다. access token 이 만료된 뒤 첫 요청마다\n * 발생하며, PWA 콜드 스타트처럼 항상 만료 상태에서 시작하는 경우 매번 겪는다.\n *\n * Next.js 는 미들웨어가 넘긴 요청 헤더를 다운스트림에 그대로 전달하므로\n * cookie 헤더를 새 토큰으로 바꿔 끼워 같은 요청에서 인증이 보이게 한다.\n * (response.headers.append 는 NextResponse 의 쿠키 프록시를 우회하기 때문에\n * Next 의 x-middleware-set-cookie 병합 경로도 타지 않는다)\n */\nfunction nextWithRefreshedCookies(\n request: NextRequest,\n setCookieHeaders: string[]\n): NextResponse {\n const merged = new Map<string, string>();\n for (const { name, value } of request.cookies.getAll()) {\n merged.set(name, value);\n }\n\n // auth 서버는 도메인마다 같은 쿠키를 한 번씩 보내므로 이름 기준으로 덮어쓴다.\n for (const header of setCookieHeaders) {\n const pair = header.split(\";\")[0] ?? \"\";\n const separator = pair.indexOf(\"=\");\n if (separator <= 0) continue;\n merged.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());\n }\n\n const requestHeaders = new Headers(request.headers);\n requestHeaders.set(\n \"cookie\",\n Array.from(merged, ([name, value]) => `${name}=${value}`).join(\"; \")\n );\n\n const response = NextResponse.next({ request: { headers: requestHeaders } });\n for (const header of setCookieHeaders) {\n response.headers.append(\"Set-Cookie\", header);\n }\n return response;\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 );\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\nasync function verifyToken(\n token: string\n): Promise<AccessTokenClaims | null> {\n try {\n const { payload } = await jwtVerify(token, jwks, {\n issuer: AUTH_SERVER_URL,\n });\n if (isAccessTokenClaims(payload)) return payload;\n // 서명은 유효한데 클레임 모양이 다르다. auth 서버와 버전이 어긋났을 때 난다.\n warnAuth(\"access token claims shape mismatch\");\n return null;\n } catch (error) {\n // 정상 만료(ERR_JWT_EXPIRED)는 갱신으로 이어지므로 소음이다. 나머지는 남긴다.\n // 특히 ERR_JWKS_NO_MATCHING_KEY 는 키 로테이션이나 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\ninterface RefreshSuccess {\n newAccessToken: string;\n setCookieHeaders: string[];\n}\n\nasync function tryRefresh(\n refreshToken: string,\n deviceId?: string\n): Promise<RefreshSuccess | null> {\n try {\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) },\n });\n\n if (!res.ok) {\n // 이 요청은 브라우저가 아니라 서버에서 나간다. UA 는 런타임 기본값이고\n // cf_clearance 같은 챌린지 통과 쿠키도 없으므로, auth 서버 앞에 WAF 나\n // 봇 방어가 있으면 origin 에 닿기 전에 잘린다. 2026-07-19 에 실제로\n // Cloudflare Bot Fight Mode 가 403 + cf-mitigated: challenge 로 막았다.\n // 그때 auth 서버 로그에는 아무것도 남지 않았다 — 도달을 못 했으니까.\n // 그래서 상태 코드와 함께 edge 가 남긴 힌트를 같이 찍는다.\n warnAuth(\"token refresh rejected\", {\n status: res.status,\n server: res.headers.get(\"server\"),\n cfRay: res.headers.get(\"cf-ray\"),\n cfMitigated: res.headers.get(\"cf-mitigated\"),\n });\n return null;\n }\n\n const setCookieHeaders = res.headers.getSetCookie();\n const newAccessToken = extractAccessToken(setCookieHeaders);\n if (!newAccessToken) {\n // 200 인데 access token 이 없다. 쿠키 이름이 어긋났거나 중간 프록시가\n // Set-Cookie 를 떨궜다. 값은 절대 찍지 않고 이름만 남긴다.\n warnAuth(\"refresh succeeded but no access token cookie\", {\n cookieNames: setCookieHeaders.map((h) => h.split(\"=\")[0]),\n });\n return null;\n }\n\n return { newAccessToken, setCookieHeaders };\n } catch (error) {\n // DNS · TLS · 타임아웃. auth 서버가 내려갔거나 경로가 막힌 경우다.\n warnAuth(\"token refresh request failed\", { code: errorCode(error) });\n return null;\n }\n}\n\nfunction buildRefreshCookieHeader(\n refreshToken: string,\n deviceId?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];\n if (deviceId) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);\n }\n return cookies.join(\"; \");\n}\n\nfunction extractAccessToken(setCookieHeaders: string[]): string | null {\n for (const header of setCookieHeaders) {\n const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));\n if (match) return match[1];\n }\n return null;\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\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, loginRedirectUrl);\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\nfunction redirectToUnapproved(\n request: NextRequest,\n unapprovedRedirectUrl?: LoginRedirectUrl\n): NextResponse {\n const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);\n if (redirectUrl) {\n return NextResponse.redirect(new URL(redirectUrl, request.url));\n }\n const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);\n errorUrl.searchParams.set(\"code\", PROXY_AUTH_ERROR.FORBIDDEN);\n errorUrl.searchParams.set(\"from\", request.url);\n return NextResponse.redirect(errorUrl);\n}\n\nfunction resolveLoginRedirectUrl(\n request: NextRequest,\n loginRedirectUrl?: LoginRedirectUrl\n): string | undefined {\n if (typeof loginRedirectUrl === \"function\") {\n return loginRedirectUrl(request);\n }\n return loginRedirectUrl;\n}\n\nexport type { AccessTokenClaims } from \"../types.js\";\nexport type { LemonUser } from \"../types.js\";\n"],"mappings":"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","names":[]}
|
package/dist/server/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
errorCode,
|
|
2
3
|
isAccessTokenClaims,
|
|
3
|
-
isApprovedClient
|
|
4
|
-
|
|
4
|
+
isApprovedClient,
|
|
5
|
+
warnAuth
|
|
6
|
+
} from "../chunk-K4U4PHX6.js";
|
|
5
7
|
import {
|
|
6
8
|
ACCESS_TOKEN_COOKIE,
|
|
7
9
|
AUTH_SERVER_URL,
|
|
@@ -21,32 +23,34 @@ import {
|
|
|
21
23
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
22
24
|
import { cookies } from "next/headers";
|
|
23
25
|
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
24
|
-
async function
|
|
25
|
-
const mockClaims = getMockAccessTokenClaims();
|
|
26
|
-
if (mockClaims) return mockClaims;
|
|
27
|
-
const cookieStore = await cookies();
|
|
28
|
-
const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
29
|
-
if (!token) return null;
|
|
26
|
+
async function verify(token) {
|
|
30
27
|
try {
|
|
31
28
|
const { payload } = await jwtVerify(token, jwks, {
|
|
32
29
|
issuer: AUTH_SERVER_URL
|
|
33
30
|
});
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
if (isAccessTokenClaims(payload)) return payload;
|
|
32
|
+
warnAuth("access token claims shape mismatch");
|
|
33
|
+
return null;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const code = errorCode(error);
|
|
36
|
+
if (code !== "ERR_JWT_EXPIRED") {
|
|
37
|
+
warnAuth("access token verification failed", { code });
|
|
38
|
+
}
|
|
36
39
|
return null;
|
|
37
40
|
}
|
|
38
41
|
}
|
|
42
|
+
async function verifyAccessToken() {
|
|
43
|
+
const mockClaims = getMockAccessTokenClaims();
|
|
44
|
+
if (mockClaims) return mockClaims;
|
|
45
|
+
const cookieStore = await cookies();
|
|
46
|
+
const token = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
47
|
+
if (!token) return null;
|
|
48
|
+
return verify(token);
|
|
49
|
+
}
|
|
39
50
|
async function verifyAccessTokenString(token) {
|
|
40
51
|
const mockClaims = getMockAccessTokenClaims();
|
|
41
52
|
if (mockClaims) return mockClaims;
|
|
42
|
-
|
|
43
|
-
const { payload } = await jwtVerify(token, jwks, {
|
|
44
|
-
issuer: AUTH_SERVER_URL
|
|
45
|
-
});
|
|
46
|
-
return isAccessTokenClaims(payload) ? payload : null;
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
53
|
+
return verify(token);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
// src/server/user.ts
|
|
@@ -110,6 +114,12 @@ async function refreshTokenFromCookie(refreshTokenCookie, deviceIdCookie) {
|
|
|
110
114
|
}
|
|
111
115
|
});
|
|
112
116
|
if (!res.ok) {
|
|
117
|
+
warnAuth("token refresh rejected", {
|
|
118
|
+
status: res.status,
|
|
119
|
+
server: res.headers.get("server"),
|
|
120
|
+
cfRay: res.headers.get("cf-ray"),
|
|
121
|
+
cfMitigated: res.headers.get("cf-mitigated")
|
|
122
|
+
});
|
|
113
123
|
return { ok: false, setCookieHeaders: [] };
|
|
114
124
|
}
|
|
115
125
|
const setCookieHeaders = res.headers.getSetCookie();
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/verify.ts","../../src/server/user.ts","../../src/server/refresh.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\";\n\nconst jwks = createRemoteJWKSet(new URL(JWKS_URL));\n\
|
|
1
|
+
{"version":3,"sources":["../../src/server/verify.ts","../../src/server/user.ts","../../src/server/refresh.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 });\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 { 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 {\n DEVICE_ID_COOKIE,\n REFRESH_TOKEN_COOKIE,\n REFRESH_URL,\n} from \"../constants.js\";\nimport { isMockAuthEnabled } from \"../mock.js\";\nimport { warnAuth } from \"../log.js\";\n\nexport interface RefreshResult {\n ok: boolean;\n setCookieHeaders: string[];\n}\n\nexport async function refreshTokenFromCookie(\n refreshTokenCookie: string,\n deviceIdCookie?: string\n): Promise<RefreshResult> {\n if (isMockAuthEnabled()) {\n return { ok: true, setCookieHeaders: [] };\n }\n\n const res = await fetch(REFRESH_URL, {\n method: \"POST\",\n headers: {\n Cookie: buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie),\n },\n });\n\n if (!res.ok) {\n // proxy 의 tryRefresh 와 같은 이유로 남긴다. 이 요청도 서버에서 나가므로\n // auth 서버 앞의 WAF · 봇 방어에 걸리면 origin 에 흔적이 남지 않는다.\n // (이 함수는 proxy 와 달리 네트워크 예외를 잡지 않고 호출자에게 던진다)\n warnAuth(\"token refresh rejected\", {\n status: res.status,\n server: res.headers.get(\"server\"),\n cfRay: res.headers.get(\"cf-ray\"),\n cfMitigated: res.headers.get(\"cf-mitigated\"),\n });\n return { ok: false, setCookieHeaders: [] };\n }\n\n const setCookieHeaders = res.headers.getSetCookie();\n return { ok: true, setCookieHeaders };\n}\n\nfunction buildRefreshCookieHeader(\n refreshTokenCookie: string,\n deviceIdCookie?: string\n): string {\n const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshTokenCookie}`];\n if (deviceIdCookie) {\n cookies.push(`${DEVICE_ID_COOKIE}=${deviceIdCookie}`);\n }\n return cookies.join(\"; \");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,oBAAoB,iBAAiB;AAC9C,SAAS,eAAe;AAOxB,IAAM,OAAO,mBAAmB,IAAI,IAAI,QAAQ,CAAC;AAQjD,eAAe,OAAO,OAAkD;AACtE,MAAI;AACF,UAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,MAAM;AAAA,MAC/C,QAAQ;AAAA,IACV,CAAC;AACD,QAAI,oBAAoB,OAAO,EAAG,QAAO;AACzC,aAAS,oCAAoC;AAC7C,WAAO;AAAA,EACT,SAAS,OAAO;AAKd,UAAM,OAAO,UAAU,KAAK;AAC5B,QAAI,SAAS,mBAAmB;AAC9B,eAAS,oCAAoC,EAAE,KAAK,CAAC;AAAA,IACvD;AACA,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,oBAAuD;AAC3E,QAAM,aAAa,yBAAyB;AAC5C,MAAI,WAAY,QAAO;AAEvB,QAAM,cAAc,MAAM,QAAQ;AAClC,QAAM,QAAQ,YAAY,IAAI,mBAAmB,GAAG;AACpD,MAAI,CAAC,MAAO,QAAO;AAEnB,SAAO,OAAO,KAAK;AACrB;AAEA,eAAsB,wBACpB,OACmC;AACnC,QAAM,aAAa,yBAAyB;AAC5C,MAAI,WAAY,QAAO;AAEvB,SAAO,OAAO,KAAK;AACrB;;;ACvDA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAMzB,SAAS,aACP,QACW;AACX,SAAO;AAAA,IACL,KAAK,OAAO;AAAA,IACZ,UAAU,OAAO;AAAA,IACjB,iBAAiB,OAAO;AAAA,IACxB,MAAM,OAAO;AAAA,IACb,iBAAiB,OAAO;AAAA,EAC1B;AACF;AAEO,IAAM,UAAU,MAAM,YAAuC;AAClE,QAAM,SAAS,MAAM,kBAAkB;AACvC,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,aAAa,MAAM;AAC5B,CAAC;AAMD,IAAM,uBAAuB;AAAA,EAC3B,OAAO,aAA6C;AAClD,UAAM,OAAO,MAAM,QAAQ;AAC3B,QAAI,CAAC,KAAM,QAAO,EAAE,MAAM,OAAO;AACjC,QAAI,CAAC,iBAAiB,KAAK,iBAAiB,QAAQ,GAAG;AACrD,aAAO,EAAE,MAAM,cAAc,KAAK;AAAA,IACpC;AACA,WAAO,EAAE,MAAM,iBAAiB,KAAK;AAAA,EACvC;AACF;AAEA,eAAsB,WACpB,UAA6B,CAAC,GACP;AACvB,SAAO,qBAAqB,QAAQ,QAAQ;AAC9C;AAEA,eAAsB,YAAY,aAAa,KAAyB;AACtE,QAAM,OAAO,MAAM,QAAQ;AAC3B,MAAI,KAAM,QAAO;AACjB,WAAS,UAAU;AACrB;AAOA,eAAsB,cACpB,UACA,sBAAqD,KACjC;AACpB,QAAM,UACJ,OAAO,wBAAwB,WAC3B,EAAE,iBAAiB,oBAAoB,IACvC;AACN,QAAM,UAAU,MAAM,WAAW,EAAE,SAAS,CAAC;AAC7C,MAAI,QAAQ,SAAS,QAAQ;AAC3B,aAAS,QAAQ,mBAAmB,GAAG;AAAA,EACzC;AACA,MAAI,QAAQ,SAAS,cAAc;AACjC;AAAA,MACE,QAAQ,wBACN,GAAG,eAAe;AAAA,IACtB;AAAA,EACF;AACA,SAAO,QAAQ;AACjB;;;AC/DA,eAAsB,uBACpB,oBACA,gBACwB;AACxB,MAAI,kBAAkB,GAAG;AACvB,WAAO,EAAE,IAAI,MAAM,kBAAkB,CAAC,EAAE;AAAA,EAC1C;AAEA,QAAM,MAAM,MAAM,MAAM,aAAa;AAAA,IACnC,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,QAAQ,yBAAyB,oBAAoB,cAAc;AAAA,IACrE;AAAA,EACF,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AAIX,aAAS,0BAA0B;AAAA,MACjC,QAAQ,IAAI;AAAA,MACZ,QAAQ,IAAI,QAAQ,IAAI,QAAQ;AAAA,MAChC,OAAO,IAAI,QAAQ,IAAI,QAAQ;AAAA,MAC/B,aAAa,IAAI,QAAQ,IAAI,cAAc;AAAA,IAC7C,CAAC;AACD,WAAO,EAAE,IAAI,OAAO,kBAAkB,CAAC,EAAE;AAAA,EAC3C;AAEA,QAAM,mBAAmB,IAAI,QAAQ,aAAa;AAClD,SAAO,EAAE,IAAI,MAAM,iBAAiB;AACtC;AAEA,SAAS,yBACP,oBACA,gBACQ;AACR,QAAMA,WAAU,CAAC,GAAG,oBAAoB,IAAI,kBAAkB,EAAE;AAChE,MAAI,gBAAgB;AAClB,IAAAA,SAAQ,KAAK,GAAG,gBAAgB,IAAI,cAAc,EAAE;AAAA,EACtD;AACA,SAAOA,SAAQ,KAAK,IAAI;AAC1B;","names":["cookies"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lemondouble/lemon-auth",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/LemonDouble/lemon-auth"
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"dist"
|
|
26
26
|
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"dev": "tsup --watch",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "pnpm run build"
|
|
32
|
+
},
|
|
33
|
+
"packageManager": "pnpm@10.25.0",
|
|
27
34
|
"peerDependencies": {
|
|
28
35
|
"next": ">=16.0.0",
|
|
29
36
|
"react": ">=19.0.0"
|
|
@@ -44,10 +51,5 @@
|
|
|
44
51
|
"@types/react": "^19.2.14",
|
|
45
52
|
"tsup": "^8.5.1",
|
|
46
53
|
"typescript": "^6.0.3"
|
|
47
|
-
},
|
|
48
|
-
"scripts": {
|
|
49
|
-
"build": "tsup",
|
|
50
|
-
"dev": "tsup --watch",
|
|
51
|
-
"typecheck": "tsc --noEmit"
|
|
52
54
|
}
|
|
53
|
-
}
|
|
55
|
+
}
|
package/dist/chunk-6W24RWSJ.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// src/claims.ts
|
|
2
|
-
function isAccessTokenClaims(payload) {
|
|
3
|
-
return payload.token_type === "access" && typeof payload.sub === "string" && typeof payload.nickname === "string" && typeof payload.profile_image_url === "string" && (payload.role === "user" || payload.role === "admin") && Array.isArray(payload.approved_clients) && payload.approved_clients.every((clientId) => typeof clientId === "string");
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
// src/approval.ts
|
|
7
|
-
function isApprovedClient(approvedClients, clientId) {
|
|
8
|
-
if (!clientId) return true;
|
|
9
|
-
return approvedClients.includes(clientId) || approvedClients.includes("*");
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
isAccessTokenClaims,
|
|
14
|
-
isApprovedClient
|
|
15
|
-
};
|
|
16
|
-
//# sourceMappingURL=chunk-6W24RWSJ.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/claims.ts","../src/approval.ts"],"sourcesContent":["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","export function isApprovedClient(\n approvedClients: string[],\n clientId?: string\n): boolean {\n if (!clientId) return true;\n return approvedClients.includes(clientId) || approvedClients.includes(\"*\");\n}\n"],"mappings":";AAGO,SAAS,oBACd,SAC8B;AAC9B,SACE,QAAQ,eAAe,YACvB,OAAO,QAAQ,QAAQ,YACvB,OAAO,QAAQ,aAAa,YAC5B,OAAO,QAAQ,sBAAsB,aACpC,QAAQ,SAAS,UAAU,QAAQ,SAAS,YAC7C,MAAM,QAAQ,QAAQ,gBAAgB,KACtC,QAAQ,iBAAiB,MAAM,CAAC,aAAa,OAAO,aAAa,QAAQ;AAE7E;;;ACfO,SAAS,iBACd,iBACA,UACS;AACT,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,gBAAgB,SAAS,QAAQ,KAAK,gBAAgB,SAAS,GAAG;AAC3E;","names":[]}
|