@lemondouble/lemon-auth 0.7.3 → 0.9.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/README.md +148 -116
- package/dist/client/index.d.ts +26 -4
- package/dist/client/index.js +50 -68
- package/dist/client/index.js.map +1 -1
- package/dist/proxy/index.d.ts +8 -7
- package/dist/proxy/index.js +32 -203
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +1 -7
- package/dist/server/index.js +47 -40
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-K4U4PHX6.js +0 -49
- package/dist/chunk-K4U4PHX6.js.map +0 -1
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/client/context.tsx","../../src/client/
|
|
1
|
+
{"version":3,"sources":["../../src/client/context.tsx","../../src/client/session-restore.tsx","../../src/client/url.ts"],"sourcesContent":["\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type { LemonUser } from \"../types.js\";\n\ninterface AuthContextValue {\n user: LemonUser | null;\n isAuthenticated: boolean;\n}\n\nconst AuthContext = createContext<AuthContextValue>({\n user: null,\n isAuthenticated: false,\n});\n\nexport function AuthProvider({\n user,\n children,\n}: {\n user: LemonUser | null;\n children: React.ReactNode;\n}) {\n return (\n <AuthContext value={{ user, isAuthenticated: user !== null }}>\n {children}\n </AuthContext>\n );\n}\n\nexport function useAuth(): AuthContextValue {\n return useContext(AuthContext);\n}\n","\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { refreshSession } from \"./url.js\";\n\n/**\n * 토큰을 갱신하는 유일한 주체. 프록시가 만료된 세션을 이리로 보낸다.\n *\n * 갱신을 서버(미들웨어)가 아니라 브라우저가 하는 이유는 두 가지다.\n *\n * 1. 미들웨어는 요청마다 별도 인스턴스로 떠서 서로를 모른다. 프리페치가\n * 깔리면 같은 refresh token 으로 갱신이 동시에 나가고, 회전 경쟁이 난다.\n * 브라우저의 복구 경로는 한 번에 하나만 뜬다.\n * 2. 브라우저 요청에는 진짜 UA 와 WAF 통과 쿠키가 실린다. 서버간 fetch 는\n * UA 가 런타임 기본값이라 봇 방어에 걸릴 수 있다.\n *\n * 끝나면 router 가 아니라 window.location 으로 next 에 간다. 소프트 네비는\n * 루트 레이아웃(과 AuthProvider)을 다시 렌더하지 않으므로, 옛 세션 스냅샷이\n * 남아 화면이 어긋나거나 라우터 캐시에 굳은 리다이렉트가 되살아난다.\n *\n * 실패해도 next 로 간다. 죽은 토큰이면 서버가 401 응답에서 세션 쿠키를\n * 지워 주므로(auth-server v2026.08.03.2+), 이후 판단 — 로그인으로 보낼지,\n * 익명으로 보여줄지 — 는 프록시와 DAL 이 알아서 한다.\n */\nexport function SessionRestore({ fallback }: { fallback: React.ReactNode }) {\n const searchParams = useSearchParams();\n const [unavailable, setUnavailable] = useState(false);\n\n useEffect(() => {\n let ignore = false;\n const next = safeNextPath(searchParams.get(\"next\"));\n\n refreshSession().then((result) => {\n if (ignore) return;\n\n if (result === \"unavailable\") {\n // 서버 · 네트워크 사정이다. 토큰은 살아 있을 수 있으므로\n // 로그인으로 보내지 않고 재시도만 권한다.\n setUnavailable(true);\n return;\n }\n\n window.location.replace(next);\n });\n\n return () => {\n ignore = true;\n };\n }, [searchParams]);\n\n if (unavailable) {\n return (\n <div\n style={{\n minHeight: \"100dvh\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 12,\n }}\n >\n <p>인증 서버에 연결하지 못했습니다.</p>\n <button type=\"button\" onClick={() => window.location.reload()}>\n 다시 시도\n </button>\n </div>\n );\n }\n\n return fallback;\n}\n\n/**\n * next 파라미터는 사용자가 조작할 수 있으므로 열린 리다이렉트가 되지 않게\n * 같은 출처의 경로만 허용한다. `//evil.com` 은 브라우저가 프로토콜 상대\n * URL 로 읽으므로 반드시 걸러야 한다.\n */\nfunction safeNextPath(next: string | null): string {\n if (!next) return \"/\";\n if (!next.startsWith(\"/\")) return \"/\";\n if (next.startsWith(\"//\")) return \"/\";\n return next;\n}\n","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":"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","names":["jsx"]}
|
package/dist/proxy/index.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { A as AccessTokenClaims } from '../types-B5uRY2Si.js';
|
|
3
|
-
export { a as LemonUser } from '../types-B5uRY2Si.js';
|
|
4
|
-
import 'jose';
|
|
5
2
|
|
|
6
3
|
declare const DEFAULT_AUTH_BYPASS_PATHS: string[];
|
|
7
4
|
declare const DEFAULT_API_PATHS: string[];
|
|
5
|
+
/** 앱이 <SessionRestore /> 를 마운트해야 하는 기본 경로. */
|
|
6
|
+
declare const DEFAULT_RESTORE_PATH = "/auth/restore";
|
|
8
7
|
declare const PROXY_AUTH_ERROR: {
|
|
9
8
|
readonly UNAUTHORIZED: "UNAUTHORIZED";
|
|
10
9
|
readonly FORBIDDEN: "FORBIDDEN";
|
|
@@ -12,14 +11,16 @@ declare const PROXY_AUTH_ERROR: {
|
|
|
12
11
|
type ProxyAuthErrorCode = (typeof PROXY_AUTH_ERROR)[keyof typeof PROXY_AUTH_ERROR];
|
|
13
12
|
type LoginRedirectUrl = string | ((request: NextRequest) => string);
|
|
14
13
|
interface AuthProxyOptions {
|
|
14
|
+
/** 로그인 없이 열어둘 경로. 익명이면 통과, 만료 세션이면 복구를 거친다. */
|
|
15
15
|
publicPaths?: string[];
|
|
16
|
+
/** 프록시 자체를 건너뛸 경로. 정적 파일 등. */
|
|
16
17
|
bypassPaths?: string[];
|
|
18
|
+
/** 리다이렉트 대신 401 JSON 을 돌려줄 경로. 기본 `/api/*`. */
|
|
17
19
|
apiPaths?: string[];
|
|
18
|
-
clientId?: string;
|
|
19
20
|
loginRedirectUrl?: LoginRedirectUrl;
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
/** <SessionRestore /> 를 마운트한 경로. 기본 `/auth/restore`. */
|
|
22
|
+
restorePath?: string;
|
|
22
23
|
}
|
|
23
24
|
declare function createAuthProxy(options?: AuthProxyOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
24
25
|
|
|
25
|
-
export {
|
|
26
|
+
export { type AuthProxyOptions, DEFAULT_API_PATHS, DEFAULT_AUTH_BYPASS_PATHS, DEFAULT_RESTORE_PATH, type LoginRedirectUrl, PROXY_AUTH_ERROR, type ProxyAuthErrorCode, createAuthProxy };
|
package/dist/proxy/index.js
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
errorCode,
|
|
3
|
-
isAccessTokenClaims,
|
|
4
|
-
isApprovedClient,
|
|
5
|
-
warnAuth
|
|
6
|
-
} from "../chunk-K4U4PHX6.js";
|
|
7
1
|
import {
|
|
8
2
|
ACCESS_TOKEN_COOKIE,
|
|
9
|
-
AUTH_SERVER_URL,
|
|
10
|
-
DEVICE_ID_COOKIE,
|
|
11
|
-
JWKS_URL,
|
|
12
3
|
LOGIN_URL,
|
|
13
4
|
REFRESH_TOKEN_COOKIE,
|
|
14
|
-
|
|
15
|
-
getMockAccessTokenClaims
|
|
5
|
+
isMockAuthEnabled
|
|
16
6
|
} from "../chunk-OX5MDVSR.js";
|
|
17
7
|
|
|
18
8
|
// src/proxy/index.ts
|
|
19
9
|
import { NextResponse } from "next/server";
|
|
20
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
21
10
|
var REFRESH_BUFFER_SECONDS = 60;
|
|
22
11
|
var DEFAULT_AUTH_BYPASS_PATHS = [
|
|
23
12
|
"/sw.js",
|
|
@@ -34,144 +23,58 @@ var DEFAULT_AUTH_BYPASS_PATHS = [
|
|
|
34
23
|
"/icons/*"
|
|
35
24
|
];
|
|
36
25
|
var DEFAULT_API_PATHS = ["/api/*"];
|
|
26
|
+
var DEFAULT_RESTORE_PATH = "/auth/restore";
|
|
37
27
|
var PROXY_AUTH_ERROR = {
|
|
38
28
|
UNAUTHORIZED: "UNAUTHORIZED",
|
|
39
29
|
FORBIDDEN: "FORBIDDEN"
|
|
40
30
|
};
|
|
41
|
-
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
42
31
|
function createAuthProxy(options = {}) {
|
|
43
32
|
const {
|
|
44
33
|
publicPaths = [],
|
|
45
34
|
bypassPaths = [],
|
|
46
35
|
apiPaths = DEFAULT_API_PATHS,
|
|
47
|
-
clientId,
|
|
48
36
|
loginRedirectUrl,
|
|
49
|
-
|
|
50
|
-
onAuthSuccess
|
|
37
|
+
restorePath = DEFAULT_RESTORE_PATH
|
|
51
38
|
} = options;
|
|
52
|
-
const effectiveBypassPaths = [
|
|
53
|
-
...DEFAULT_AUTH_BYPASS_PATHS,
|
|
54
|
-
...bypassPaths
|
|
55
|
-
];
|
|
39
|
+
const effectiveBypassPaths = [...DEFAULT_AUTH_BYPASS_PATHS, ...bypassPaths];
|
|
56
40
|
return async function proxy(request) {
|
|
57
41
|
const { pathname } = request.nextUrl;
|
|
58
|
-
if (
|
|
42
|
+
if (pathname === restorePath) {
|
|
59
43
|
return NextResponse.next();
|
|
60
44
|
}
|
|
61
|
-
if (isPublicPath(pathname,
|
|
62
|
-
return
|
|
45
|
+
if (isPublicPath(pathname, effectiveBypassPaths)) {
|
|
46
|
+
return NextResponse.next();
|
|
63
47
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (mockClaims) {
|
|
67
|
-
return handleVerifiedClaims(
|
|
68
|
-
mockClaims,
|
|
69
|
-
request,
|
|
70
|
-
isApiRequest,
|
|
71
|
-
clientId,
|
|
72
|
-
unapprovedRedirectUrl,
|
|
73
|
-
onAuthSuccess,
|
|
74
|
-
NextResponse.next()
|
|
75
|
-
);
|
|
48
|
+
if (isMockAuthEnabled()) {
|
|
49
|
+
return NextResponse.next();
|
|
76
50
|
}
|
|
77
51
|
const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
78
|
-
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
|
|
79
|
-
const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;
|
|
80
52
|
if (accessToken && isTokenFresh(accessToken)) {
|
|
81
|
-
|
|
82
|
-
if (claims) {
|
|
83
|
-
return handleVerifiedClaims(
|
|
84
|
-
claims,
|
|
85
|
-
request,
|
|
86
|
-
isApiRequest,
|
|
87
|
-
clientId,
|
|
88
|
-
unapprovedRedirectUrl,
|
|
89
|
-
onAuthSuccess,
|
|
90
|
-
NextResponse.next()
|
|
91
|
-
);
|
|
92
|
-
}
|
|
53
|
+
return NextResponse.next();
|
|
93
54
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
if (claims) {
|
|
99
|
-
const response = nextWithRefreshedCookies(
|
|
100
|
-
request,
|
|
101
|
-
result.setCookieHeaders
|
|
102
|
-
);
|
|
103
|
-
return handleVerifiedClaims(
|
|
104
|
-
claims,
|
|
105
|
-
request,
|
|
106
|
-
isApiRequest,
|
|
107
|
-
clientId,
|
|
108
|
-
unapprovedRedirectUrl,
|
|
109
|
-
onAuthSuccess,
|
|
110
|
-
response
|
|
111
|
-
);
|
|
112
|
-
}
|
|
55
|
+
const isPublic = isPublicPath(pathname, publicPaths);
|
|
56
|
+
if (isPublicPath(pathname, apiPaths)) {
|
|
57
|
+
if (isPublic) {
|
|
58
|
+
return NextResponse.next();
|
|
113
59
|
}
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ code: PROXY_AUTH_ERROR.UNAUTHORIZED },
|
|
62
|
+
{ status: 401 }
|
|
63
|
+
);
|
|
114
64
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
async function handleVerifiedClaims(claims, request, isApiRequest, clientId, unapprovedRedirectUrl, onAuthSuccess, response) {
|
|
119
|
-
if (!isApprovedClient(claims.approved_clients, clientId)) {
|
|
120
|
-
return isApiRequest ? forbiddenJson() : redirectToUnapproved(request, unapprovedRedirectUrl);
|
|
121
|
-
}
|
|
122
|
-
return onAuthSuccess ? await onAuthSuccess(claims, request, response) : response;
|
|
123
|
-
}
|
|
124
|
-
function unauthorizedJson() {
|
|
125
|
-
return NextResponse.json(
|
|
126
|
-
{ code: PROXY_AUTH_ERROR.UNAUTHORIZED },
|
|
127
|
-
{ status: 401 }
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
function forbiddenJson() {
|
|
131
|
-
return NextResponse.json(
|
|
132
|
-
{ code: PROXY_AUTH_ERROR.FORBIDDEN },
|
|
133
|
-
{ status: 403 }
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
async function maybeRefreshAndContinue(request) {
|
|
137
|
-
const accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
|
|
138
|
-
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
|
|
139
|
-
const deviceId = request.cookies.get(DEVICE_ID_COOKIE)?.value;
|
|
140
|
-
if (accessToken && isTokenFresh(accessToken)) {
|
|
141
|
-
return NextResponse.next();
|
|
142
|
-
}
|
|
143
|
-
if (refreshToken) {
|
|
144
|
-
const result = await tryRefresh(refreshToken, deviceId);
|
|
145
|
-
if (result) {
|
|
146
|
-
return nextWithRefreshedCookies(request, result.setCookieHeaders);
|
|
65
|
+
if (request.method !== "GET") {
|
|
66
|
+
return NextResponse.next();
|
|
147
67
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
68
|
+
if (request.cookies.get(REFRESH_TOKEN_COOKIE)?.value) {
|
|
69
|
+
const restore = new URL(restorePath, request.url);
|
|
70
|
+
restore.searchParams.set("next", pathname + request.nextUrl.search);
|
|
71
|
+
return NextResponse.redirect(restore);
|
|
72
|
+
}
|
|
73
|
+
if (isPublic) {
|
|
74
|
+
return NextResponse.next();
|
|
75
|
+
}
|
|
76
|
+
return redirectToLogin(request, loginRedirectUrl);
|
|
77
|
+
};
|
|
175
78
|
}
|
|
176
79
|
function isTokenFresh(token) {
|
|
177
80
|
try {
|
|
@@ -185,65 +88,6 @@ function isTokenFresh(token) {
|
|
|
185
88
|
return false;
|
|
186
89
|
}
|
|
187
90
|
}
|
|
188
|
-
async function verifyToken(token) {
|
|
189
|
-
try {
|
|
190
|
-
const { payload } = await jwtVerify(token, jwks, {
|
|
191
|
-
issuer: AUTH_SERVER_URL
|
|
192
|
-
});
|
|
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
|
-
}
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
async function tryRefresh(refreshToken, deviceId) {
|
|
205
|
-
try {
|
|
206
|
-
const res = await fetch(REFRESH_URL, {
|
|
207
|
-
method: "POST",
|
|
208
|
-
headers: { Cookie: buildRefreshCookieHeader(refreshToken, deviceId) }
|
|
209
|
-
});
|
|
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
|
-
}
|
|
219
|
-
const setCookieHeaders = res.headers.getSetCookie();
|
|
220
|
-
const newAccessToken = extractAccessToken(setCookieHeaders);
|
|
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
|
-
}
|
|
227
|
-
return { newAccessToken, setCookieHeaders };
|
|
228
|
-
} catch (error) {
|
|
229
|
-
warnAuth("token refresh request failed", { code: errorCode(error) });
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
function buildRefreshCookieHeader(refreshToken, deviceId) {
|
|
234
|
-
const cookies = [`${REFRESH_TOKEN_COOKIE}=${refreshToken}`];
|
|
235
|
-
if (deviceId) {
|
|
236
|
-
cookies.push(`${DEVICE_ID_COOKIE}=${deviceId}`);
|
|
237
|
-
}
|
|
238
|
-
return cookies.join("; ");
|
|
239
|
-
}
|
|
240
|
-
function extractAccessToken(setCookieHeaders) {
|
|
241
|
-
for (const header of setCookieHeaders) {
|
|
242
|
-
const match = header.match(new RegExp(`^${ACCESS_TOKEN_COOKIE}=([^;]+)`));
|
|
243
|
-
if (match) return match[1];
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
91
|
function isPublicPath(pathname, publicPaths) {
|
|
248
92
|
return publicPaths.some((p) => {
|
|
249
93
|
if (p.endsWith("*")) {
|
|
@@ -253,32 +97,17 @@ function isPublicPath(pathname, publicPaths) {
|
|
|
253
97
|
});
|
|
254
98
|
}
|
|
255
99
|
function redirectToLogin(request, loginRedirectUrl) {
|
|
256
|
-
const redirectUrl =
|
|
100
|
+
const redirectUrl = typeof loginRedirectUrl === "function" ? loginRedirectUrl(request) : loginRedirectUrl;
|
|
257
101
|
if (redirectUrl) {
|
|
258
102
|
const url = `${LOGIN_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`;
|
|
259
103
|
return NextResponse.redirect(new URL(url, request.url));
|
|
260
104
|
}
|
|
261
105
|
return NextResponse.redirect(new URL("/", request.url));
|
|
262
106
|
}
|
|
263
|
-
function redirectToUnapproved(request, unapprovedRedirectUrl) {
|
|
264
|
-
const redirectUrl = resolveLoginRedirectUrl(request, unapprovedRedirectUrl);
|
|
265
|
-
if (redirectUrl) {
|
|
266
|
-
return NextResponse.redirect(new URL(redirectUrl, request.url));
|
|
267
|
-
}
|
|
268
|
-
const errorUrl = new URL(`${AUTH_SERVER_URL}/error`);
|
|
269
|
-
errorUrl.searchParams.set("code", PROXY_AUTH_ERROR.FORBIDDEN);
|
|
270
|
-
errorUrl.searchParams.set("from", request.url);
|
|
271
|
-
return NextResponse.redirect(errorUrl);
|
|
272
|
-
}
|
|
273
|
-
function resolveLoginRedirectUrl(request, loginRedirectUrl) {
|
|
274
|
-
if (typeof loginRedirectUrl === "function") {
|
|
275
|
-
return loginRedirectUrl(request);
|
|
276
|
-
}
|
|
277
|
-
return loginRedirectUrl;
|
|
278
|
-
}
|
|
279
107
|
export {
|
|
280
108
|
DEFAULT_API_PATHS,
|
|
281
109
|
DEFAULT_AUTH_BYPASS_PATHS,
|
|
110
|
+
DEFAULT_RESTORE_PATH,
|
|
282
111
|
PROXY_AUTH_ERROR,
|
|
283
112
|
createAuthProxy
|
|
284
113
|
};
|
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\";\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":[]}
|
|
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":[]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -17,12 +17,6 @@ interface RequireClientOptions {
|
|
|
17
17
|
}
|
|
18
18
|
declare function requireClient(clientId: string, optionsOrRedirectTo?: string | RequireClientOptions): Promise<LemonUser>;
|
|
19
19
|
|
|
20
|
-
interface RefreshResult {
|
|
21
|
-
ok: boolean;
|
|
22
|
-
setCookieHeaders: string[];
|
|
23
|
-
}
|
|
24
|
-
declare function refreshTokenFromCookie(refreshTokenCookie: string, deviceIdCookie?: string): Promise<RefreshResult>;
|
|
25
|
-
|
|
26
20
|
declare const AUTH_SERVER_URL = "https://auth.lemondouble.com";
|
|
27
21
|
declare const JWKS_URL = "https://auth.lemondouble.com/.well-known/jwks.json";
|
|
28
22
|
declare const LOGIN_URL = "https://auth.lemondouble.com/api/oauth2/google/login";
|
|
@@ -34,4 +28,4 @@ declare const ACCESS_TOKEN_COOKIE = "lemon_access_token";
|
|
|
34
28
|
declare const REFRESH_TOKEN_COOKIE = "lemon_refresh_token";
|
|
35
29
|
declare const DEVICE_ID_COOKIE = "device_id";
|
|
36
30
|
|
|
37
|
-
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
|
|
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 };
|
package/dist/server/index.js
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
errorCode,
|
|
3
|
-
isAccessTokenClaims,
|
|
4
|
-
isApprovedClient,
|
|
5
|
-
warnAuth
|
|
6
|
-
} from "../chunk-K4U4PHX6.js";
|
|
7
1
|
import {
|
|
8
2
|
ACCESS_TOKEN_COOKIE,
|
|
9
3
|
AUTH_SERVER_URL,
|
|
@@ -15,13 +9,41 @@ import {
|
|
|
15
9
|
PROFILE_URL,
|
|
16
10
|
REFRESH_TOKEN_COOKIE,
|
|
17
11
|
REFRESH_URL,
|
|
18
|
-
getMockAccessTokenClaims
|
|
19
|
-
isMockAuthEnabled
|
|
12
|
+
getMockAccessTokenClaims
|
|
20
13
|
} from "../chunk-OX5MDVSR.js";
|
|
21
14
|
|
|
22
15
|
// src/server/verify.ts
|
|
23
16
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
24
17
|
import { cookies } from "next/headers";
|
|
18
|
+
|
|
19
|
+
// src/claims.ts
|
|
20
|
+
function isAccessTokenClaims(payload) {
|
|
21
|
+
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");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/log.ts
|
|
25
|
+
var PREFIX = "[lemon-auth]";
|
|
26
|
+
function warnAuth(event, detail) {
|
|
27
|
+
if (detail === void 0) {
|
|
28
|
+
console.warn(`${PREFIX} ${event}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.warn(`${PREFIX} ${event}`, JSON.stringify(detail));
|
|
32
|
+
}
|
|
33
|
+
function errorCode(error) {
|
|
34
|
+
if (typeof error === "object" && error !== null) {
|
|
35
|
+
const { code, cause } = error;
|
|
36
|
+
if (typeof code === "string") return code;
|
|
37
|
+
if (typeof cause === "object" && cause !== null) {
|
|
38
|
+
const causeCode = cause.code;
|
|
39
|
+
if (typeof causeCode === "string") return causeCode;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (error instanceof Error) return error.name;
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/server/verify.ts
|
|
25
47
|
var jwks = createRemoteJWKSet(new URL(JWKS_URL));
|
|
26
48
|
async function verify(token) {
|
|
27
49
|
try {
|
|
@@ -56,6 +78,23 @@ async function verifyAccessTokenString(token) {
|
|
|
56
78
|
// src/server/user.ts
|
|
57
79
|
import { cache } from "react";
|
|
58
80
|
import { redirect } from "next/navigation";
|
|
81
|
+
|
|
82
|
+
// src/approval.ts
|
|
83
|
+
var warnedMissingClientId = false;
|
|
84
|
+
function isApprovedClient(approvedClients, clientId) {
|
|
85
|
+
if (!clientId) {
|
|
86
|
+
if (!warnedMissingClientId) {
|
|
87
|
+
warnedMissingClientId = true;
|
|
88
|
+
warnAuth(
|
|
89
|
+
"clientId not provided \u2014 approval check is disabled (all users pass). \uC124\uC815 \uB204\uB77D\uC774 \uC544\uB2CC\uC9C0 \uD655\uC778\uD560 \uAC83."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return approvedClients.includes(clientId) || approvedClients.includes("*");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/server/user.ts
|
|
59
98
|
function claimsToUser(claims) {
|
|
60
99
|
return {
|
|
61
100
|
uid: claims.sub,
|
|
@@ -101,37 +140,6 @@ async function requireClient(clientId, optionsOrRedirectTo = "/") {
|
|
|
101
140
|
}
|
|
102
141
|
return session.user;
|
|
103
142
|
}
|
|
104
|
-
|
|
105
|
-
// src/server/refresh.ts
|
|
106
|
-
async function refreshTokenFromCookie(refreshTokenCookie, deviceIdCookie) {
|
|
107
|
-
if (isMockAuthEnabled()) {
|
|
108
|
-
return { ok: true, setCookieHeaders: [] };
|
|
109
|
-
}
|
|
110
|
-
const res = await fetch(REFRESH_URL, {
|
|
111
|
-
method: "POST",
|
|
112
|
-
headers: {
|
|
113
|
-
Cookie: buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie)
|
|
114
|
-
}
|
|
115
|
-
});
|
|
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
|
-
});
|
|
123
|
-
return { ok: false, setCookieHeaders: [] };
|
|
124
|
-
}
|
|
125
|
-
const setCookieHeaders = res.headers.getSetCookie();
|
|
126
|
-
return { ok: true, setCookieHeaders };
|
|
127
|
-
}
|
|
128
|
-
function buildRefreshCookieHeader(refreshTokenCookie, deviceIdCookie) {
|
|
129
|
-
const cookies2 = [`${REFRESH_TOKEN_COOKIE}=${refreshTokenCookie}`];
|
|
130
|
-
if (deviceIdCookie) {
|
|
131
|
-
cookies2.push(`${DEVICE_ID_COOKIE}=${deviceIdCookie}`);
|
|
132
|
-
}
|
|
133
|
-
return cookies2.join("; ");
|
|
134
|
-
}
|
|
135
143
|
export {
|
|
136
144
|
ACCESS_TOKEN_COOKIE,
|
|
137
145
|
AUTH_SERVER_URL,
|
|
@@ -145,7 +153,6 @@ export {
|
|
|
145
153
|
REFRESH_URL,
|
|
146
154
|
getSession,
|
|
147
155
|
getUser,
|
|
148
|
-
refreshTokenFromCookie,
|
|
149
156
|
requireAuth,
|
|
150
157
|
requireClient,
|
|
151
158
|
verifyAccessToken,
|