@drvalue-oss/iam-next 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 drvalue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @drvalue-oss/iam-next
2
+
3
+ Next.js Route Handler factories for the drvalue IAM **BFF token-exchange pattern**:
4
+
5
+ - Refresh token lives in an httpOnly cookie on YOUR Next.js app (never touches the browser JS).
6
+ - Access token is delivered to the browser via URL hash, then stored in localStorage by the SPA.
7
+ - The Next.js app proxies refresh / revoke calls to IAM server-to-server, so the browser never CORS-talks to IAM directly.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @drvalue-oss/iam-next
13
+ ```
14
+
15
+ Peer dep: `next ^14 || ^15 || ^16`.
16
+
17
+ ## Setup
18
+
19
+ Create one shared config and three Route Handlers:
20
+
21
+ ```ts
22
+ // lib/iam.ts
23
+ import type { IamNextConfig } from '@drvalue-oss/iam-next';
24
+
25
+ export const iamConfig: IamNextConfig = {
26
+ iamServerUrl: process.env.IAM_SERVER_URL!, // https://iam.drvalue.co.kr
27
+ appUrl: process.env.NEXT_PUBLIC_APP_URL!, // https://app.drvalue.co.kr
28
+ cookieDomain: process.env.COOKIE_DOMAIN, // .drvalue.co.kr (optional)
29
+ internalApiKey: process.env.INTERNAL_API_KEY, // for token revoke (optional)
30
+ };
31
+ ```
32
+
33
+ ```ts
34
+ // app/auth/callback/route.ts
35
+ import { createCallbackHandler } from '@drvalue-oss/iam-next';
36
+ import { iamConfig } from '@/lib/iam';
37
+
38
+ export const GET = createCallbackHandler({
39
+ ...iamConfig,
40
+ resolveLoginPath: (origin) => (origin === 'admin' ? '/admin/login' : '/login'),
41
+ });
42
+ ```
43
+
44
+ ```ts
45
+ // app/api/auth/refresh/route.ts
46
+ import { createRefreshHandler } from '@drvalue-oss/iam-next';
47
+ import { iamConfig } from '@/lib/iam';
48
+
49
+ export const POST = createRefreshHandler(iamConfig);
50
+ ```
51
+
52
+ ```ts
53
+ // app/api/auth/logout/route.ts
54
+ import { createLogoutHandler } from '@drvalue-oss/iam-next';
55
+ import { iamConfig } from '@/lib/iam';
56
+
57
+ export const POST = createLogoutHandler(iamConfig);
58
+ ```
59
+
60
+ ## The /auth/complete bridge page
61
+
62
+ The callback handler redirects to `/auth/complete#access_token=...&expires_in=...` so the access token never lands in a server log. Implement the bridge yourself — it needs to:
63
+
64
+ 1. Parse the hash, call `setAccessToken(token, expiresIn)` from `@drvalue-oss/iam-react`.
65
+ 2. (optional) Fetch `/me` to populate your auth store.
66
+ 3. Redirect to the user's destination (admin dashboard / user dashboard / etc).
67
+
68
+ See [`examples/nextjs-app/src/app/auth/complete/page.tsx`](../../examples/nextjs-app/src/app/auth/complete/page.tsx) for a reference implementation.
69
+
70
+ ## Why the URL hash
71
+
72
+ The hash is **not sent to the server** on navigation. The Next.js server never sees the access token. Combined with `window.history.replaceState` on the complete page, the token also disappears from the browser URL bar after handoff.
73
+
74
+ ## License
75
+
76
+ [MIT](../../LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ var server = require('next/server');
4
+ var iamCore = require('@drvalue-oss/iam-core');
5
+
6
+ // src/handlers/callback.ts
7
+ function resolveConfig(input) {
8
+ if (!input.iamServerUrl) throw new Error("iam-next: iamServerUrl is required");
9
+ if (!input.appUrl) throw new Error("iam-next: appUrl is required");
10
+ return {
11
+ iamServerUrl: input.iamServerUrl.replace(/\/$/, ""),
12
+ appUrl: input.appUrl.replace(/\/$/, ""),
13
+ cookieDomain: input.cookieDomain,
14
+ refreshCookieName: input.refreshCookieName ?? iamCore.STORAGE_KEYS.REFRESH_TOKEN,
15
+ authOriginCookieName: input.authOriginCookieName ?? iamCore.STORAGE_KEYS.AUTH_ORIGIN,
16
+ refreshCookieMaxAgeSeconds: input.refreshCookieMaxAgeSeconds ?? 7 * 24 * 60 * 60,
17
+ internalApiKey: input.internalApiKey
18
+ };
19
+ }
20
+
21
+ // src/cookies.ts
22
+ function setRefreshCookie(response, config, value) {
23
+ response.cookies.set(config.refreshCookieName, value, {
24
+ httpOnly: true,
25
+ secure: process.env.NODE_ENV === "production",
26
+ sameSite: "lax",
27
+ path: "/",
28
+ maxAge: config.refreshCookieMaxAgeSeconds,
29
+ ...config.cookieDomain ? { domain: config.cookieDomain } : {}
30
+ });
31
+ }
32
+ function clearRefreshCookie(response, config) {
33
+ response.cookies.set(config.refreshCookieName, "", {
34
+ httpOnly: true,
35
+ secure: process.env.NODE_ENV === "production",
36
+ sameSite: "lax",
37
+ path: "/",
38
+ maxAge: 0,
39
+ ...config.cookieDomain ? { domain: config.cookieDomain } : {}
40
+ });
41
+ }
42
+ function setAuthOriginCookie(response, config, value) {
43
+ response.cookies.set(config.authOriginCookieName, value, {
44
+ httpOnly: false,
45
+ secure: process.env.NODE_ENV === "production",
46
+ sameSite: "lax",
47
+ path: "/",
48
+ maxAge: 5 * 60,
49
+ ...config.cookieDomain ? { domain: config.cookieDomain } : {}
50
+ });
51
+ }
52
+
53
+ // src/handlers/callback.ts
54
+ function parseTokens(raw) {
55
+ if (!raw || typeof raw !== "object") return null;
56
+ const r = raw;
57
+ if (typeof r.access_token !== "string" || typeof r.refresh_token !== "string") {
58
+ return null;
59
+ }
60
+ return {
61
+ access_token: r.access_token,
62
+ refresh_token: r.refresh_token,
63
+ expires_in: typeof r.expires_in === "number" ? r.expires_in : 900
64
+ };
65
+ }
66
+ function createCallbackHandler(options) {
67
+ const config = resolveConfig(options);
68
+ const callbackPath = options.callbackPath ?? "/auth/callback";
69
+ const completePath = options.completePath ?? "/auth/complete";
70
+ const resolveLoginPath = options.resolveLoginPath ?? (() => "/login");
71
+ return async function GET(request) {
72
+ const code = request.nextUrl.searchParams.get("code");
73
+ const from = request.nextUrl.searchParams.get("from") ?? "user";
74
+ const loginPath = resolveLoginPath(from);
75
+ if (!code) {
76
+ return server.NextResponse.redirect(new URL(`${loginPath}?error=missing_code`, config.appUrl));
77
+ }
78
+ try {
79
+ const tokenResponse = await fetch(
80
+ `${config.iamServerUrl}${iamCore.IAM_ENDPOINTS.TOKEN_EXCHANGE}`,
81
+ {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({
85
+ code,
86
+ redirectUri: `${config.appUrl}${callbackPath}?from=${from}`
87
+ })
88
+ }
89
+ );
90
+ const body = await tokenResponse.json().catch(() => null);
91
+ if (!tokenResponse.ok) {
92
+ console.error("[iam-next/callback] token exchange failed:", body);
93
+ return server.NextResponse.redirect(
94
+ new URL(`${loginPath}?error=token_exchange_failed`, config.appUrl)
95
+ );
96
+ }
97
+ const tokens = parseTokens(body);
98
+ if (!tokens) {
99
+ console.error("[iam-next/callback] token exchange returned 200 with missing fields:", body);
100
+ return server.NextResponse.redirect(
101
+ new URL(`${loginPath}?error=token_exchange_invalid`, config.appUrl)
102
+ );
103
+ }
104
+ const redirectUrl = new URL(completePath, config.appUrl);
105
+ redirectUrl.hash = `access_token=${tokens.access_token}&expires_in=${tokens.expires_in}`;
106
+ const response = server.NextResponse.redirect(redirectUrl);
107
+ setRefreshCookie(response, config, tokens.refresh_token);
108
+ setAuthOriginCookie(response, config, from);
109
+ return response;
110
+ } catch (error) {
111
+ console.error("[iam-next/callback] unexpected error:", error);
112
+ return server.NextResponse.redirect(new URL(`${loginPath}?error=server_error`, config.appUrl));
113
+ }
114
+ };
115
+ }
116
+ function parseTokens2(raw) {
117
+ if (!raw || typeof raw !== "object") return null;
118
+ const r = raw;
119
+ if (typeof r.access_token !== "string" || typeof r.refresh_token !== "string") {
120
+ return null;
121
+ }
122
+ return {
123
+ access_token: r.access_token,
124
+ refresh_token: r.refresh_token,
125
+ expires_in: typeof r.expires_in === "number" ? r.expires_in : 900
126
+ };
127
+ }
128
+ function createRefreshHandler(options) {
129
+ const config = resolveConfig(options);
130
+ return async function POST(request) {
131
+ const refreshToken = request.cookies.get(config.refreshCookieName)?.value;
132
+ if (!refreshToken) {
133
+ return server.NextResponse.json({ error: "No refresh token" }, { status: 401 });
134
+ }
135
+ try {
136
+ const tokenResponse = await fetch(
137
+ `${config.iamServerUrl}${iamCore.IAM_ENDPOINTS.TOKEN_REFRESH}`,
138
+ {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify({ refresh_token: refreshToken })
142
+ }
143
+ );
144
+ const body = await tokenResponse.json().catch(() => null);
145
+ const tokens = tokenResponse.ok ? parseTokens2(body) : null;
146
+ if (!tokens) {
147
+ const response2 = server.NextResponse.json({ error: "Refresh failed" }, { status: 401 });
148
+ clearRefreshCookie(response2, config);
149
+ return response2;
150
+ }
151
+ const response = server.NextResponse.json({
152
+ access_token: tokens.access_token,
153
+ expires_in: tokens.expires_in
154
+ });
155
+ setRefreshCookie(response, config, tokens.refresh_token);
156
+ return response;
157
+ } catch (error) {
158
+ console.error("[iam-next/refresh] unexpected error:", error);
159
+ return server.NextResponse.json({ error: "Server error" }, { status: 500 });
160
+ }
161
+ };
162
+ }
163
+ function createLogoutHandler(options) {
164
+ const config = resolveConfig(options);
165
+ return async function POST(request) {
166
+ const refreshToken = request.cookies.get(config.refreshCookieName)?.value;
167
+ if (refreshToken && refreshToken.includes(".")) {
168
+ try {
169
+ const payload = JSON.parse(
170
+ Buffer.from(refreshToken.split(".")[1] ?? "", "base64url").toString()
171
+ );
172
+ if (payload.sub) {
173
+ await fetch(`${config.iamServerUrl}${iamCore.IAM_ENDPOINTS.TOKEN_REVOKE}`, {
174
+ method: "POST",
175
+ headers: {
176
+ "Content-Type": "application/json",
177
+ ...config.internalApiKey ? { "X-Internal-Api-Key": config.internalApiKey } : {}
178
+ },
179
+ body: JSON.stringify({ user_id: payload.sub })
180
+ });
181
+ }
182
+ } catch (error) {
183
+ console.error("[iam-next/logout] IAM token revoke failed (best-effort):", error);
184
+ }
185
+ }
186
+ const response = server.NextResponse.json({ success: true });
187
+ clearRefreshCookie(response, config);
188
+ return response;
189
+ };
190
+ }
191
+
192
+ Object.defineProperty(exports, "IAM_ENDPOINTS", {
193
+ enumerable: true,
194
+ get: function () { return iamCore.IAM_ENDPOINTS; }
195
+ });
196
+ Object.defineProperty(exports, "buildLoginUrl", {
197
+ enumerable: true,
198
+ get: function () { return iamCore.buildLoginUrl; }
199
+ });
200
+ Object.defineProperty(exports, "buildRegisterUrl", {
201
+ enumerable: true,
202
+ get: function () { return iamCore.buildRegisterUrl; }
203
+ });
204
+ exports.createCallbackHandler = createCallbackHandler;
205
+ exports.createLogoutHandler = createLogoutHandler;
206
+ exports.createRefreshHandler = createRefreshHandler;
207
+ //# sourceMappingURL=index.cjs.map
208
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts","../src/cookies.ts","../src/handlers/callback.ts","../src/handlers/refresh.ts","../src/handlers/logout.ts"],"names":["STORAGE_KEYS","NextResponse","IAM_ENDPOINTS","parseTokens","response"],"mappings":"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","file":"index.cjs","sourcesContent":["import { STORAGE_KEYS } from '@drvalue-oss/iam-core';\n\nexport interface IamNextConfig {\n /** Base URL of your IAM server. Required. e.g. `https://iam.drvalue.co.kr` */\n iamServerUrl: string;\n /** Base URL of THIS Next.js app. Required. e.g. `https://app.drvalue.co.kr` */\n appUrl: string;\n /**\n * Domain for the refresh-token cookie. Set to e.g. `.drvalue.co.kr`\n * to share the cookie across sub-apps. Leave undefined for\n * single-host deployments.\n */\n cookieDomain?: string;\n /**\n * Cookie name for the httpOnly refresh token. Defaults to\n * `STORAGE_KEYS.REFRESH_TOKEN`. Override when two drvalue apps\n * share an origin but should NOT share refresh tokens.\n */\n refreshCookieName?: string;\n /**\n * Cookie name for the short-lived `auth_origin` marker. Defaults to\n * `STORAGE_KEYS.AUTH_ORIGIN`.\n */\n authOriginCookieName?: string;\n /** Refresh-token cookie max age in seconds. Defaults to 7 days. */\n refreshCookieMaxAgeSeconds?: number;\n /**\n * Internal API key forwarded to IAM `/auth/token/revoke` so the\n * server can authorize cross-service token revocation. Optional.\n */\n internalApiKey?: string;\n}\n\nexport interface ResolvedIamNextConfig extends Required<Omit<IamNextConfig, 'cookieDomain' | 'internalApiKey'>> {\n cookieDomain?: string;\n internalApiKey?: string;\n}\n\nexport function resolveConfig(input: IamNextConfig): ResolvedIamNextConfig {\n if (!input.iamServerUrl) throw new Error('iam-next: iamServerUrl is required');\n if (!input.appUrl) throw new Error('iam-next: appUrl is required');\n return {\n iamServerUrl: input.iamServerUrl.replace(/\\/$/, ''),\n appUrl: input.appUrl.replace(/\\/$/, ''),\n cookieDomain: input.cookieDomain,\n refreshCookieName: input.refreshCookieName ?? STORAGE_KEYS.REFRESH_TOKEN,\n authOriginCookieName: input.authOriginCookieName ?? STORAGE_KEYS.AUTH_ORIGIN,\n refreshCookieMaxAgeSeconds: input.refreshCookieMaxAgeSeconds ?? 7 * 24 * 60 * 60,\n internalApiKey: input.internalApiKey,\n };\n}\n","import type { NextResponse } from 'next/server';\nimport type { ResolvedIamNextConfig } from './config.js';\n\nexport function setRefreshCookie(\n response: NextResponse,\n config: ResolvedIamNextConfig,\n value: string,\n): void {\n response.cookies.set(config.refreshCookieName, value, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: config.refreshCookieMaxAgeSeconds,\n ...(config.cookieDomain ? { domain: config.cookieDomain } : {}),\n });\n}\n\nexport function clearRefreshCookie(\n response: NextResponse,\n config: ResolvedIamNextConfig,\n): void {\n response.cookies.set(config.refreshCookieName, '', {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: 0,\n ...(config.cookieDomain ? { domain: config.cookieDomain } : {}),\n });\n}\n\n/**\n * Short-lived (5 min), client-readable. Tells the /auth/complete page\n * where to redirect after token handoff (e.g. user vs admin dashboard).\n */\nexport function setAuthOriginCookie(\n response: NextResponse,\n config: ResolvedIamNextConfig,\n value: string,\n): void {\n response.cookies.set(config.authOriginCookieName, value, {\n httpOnly: false,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: 5 * 60,\n ...(config.cookieDomain ? { domain: config.cookieDomain } : {}),\n });\n}\n","import { NextResponse, type NextRequest } from 'next/server';\nimport { IAM_ENDPOINTS } from '@drvalue-oss/iam-core';\nimport { resolveConfig, type IamNextConfig } from '../config.js';\nimport { setAuthOriginCookie, setRefreshCookie } from '../cookies.js';\n\nexport interface CreateCallbackHandlerOptions extends IamNextConfig {\n /**\n * Path on this app where the callback handler lives. Used to\n * reconstruct `redirectUri` for the token exchange. Defaults to\n * `/auth/callback`.\n */\n callbackPath?: string;\n /**\n * Path the browser is redirected to after a successful token\n * exchange. The access token is appended in the URL hash so it\n * never hits the server log. Defaults to `/auth/complete`.\n */\n completePath?: string;\n /**\n * Map an `auth_origin` value (read from `?from=...`) to the login\n * path used when the exchange fails. Default returns `/login`.\n */\n resolveLoginPath?: (origin: string) => string;\n}\n\ninterface ParsedTokens {\n access_token: string;\n refresh_token: string;\n expires_in: number;\n}\n\nfunction parseTokens(raw: unknown): ParsedTokens | null {\n if (!raw || typeof raw !== 'object') return null;\n const r = raw as Record<string, unknown>;\n if (\n typeof r.access_token !== 'string' ||\n typeof r.refresh_token !== 'string'\n ) {\n return null;\n }\n return {\n access_token: r.access_token,\n refresh_token: r.refresh_token,\n expires_in: typeof r.expires_in === 'number' ? r.expires_in : 900,\n };\n}\n\n/**\n * Factory for the GET handler at `/auth/callback`.\n *\n * Flow:\n * IAM → ?code=xxx&from=user\n * → exchange code for tokens via IAM `/auth/token/exchange`\n * → set refresh_token httpOnly cookie\n * → set short-lived auth_origin cookie\n * → 302 to /auth/complete#access_token=...&expires_in=...\n *\n * Why the hash: access token never lands in the Next.js server log\n * or the Referer header of downstream navigations.\n *\n * Why we re-validate the response body even on 2xx: IAM has historically\n * had bugs where it returns HTTP 200 with `{\"error\": \"...\"}` and no\n * `access_token` field. If we let those through we'd 302 the browser\n * to `/auth/complete#access_token=undefined`, which then triggers the\n * SPA's refresh loop indefinitely. Treat missing fields as a hard fail.\n */\nexport function createCallbackHandler(options: CreateCallbackHandlerOptions) {\n const config = resolveConfig(options);\n const callbackPath = options.callbackPath ?? '/auth/callback';\n const completePath = options.completePath ?? '/auth/complete';\n const resolveLoginPath = options.resolveLoginPath ?? (() => '/login');\n\n return async function GET(request: NextRequest): Promise<NextResponse> {\n const code = request.nextUrl.searchParams.get('code');\n const from = request.nextUrl.searchParams.get('from') ?? 'user';\n const loginPath = resolveLoginPath(from);\n\n if (!code) {\n return NextResponse.redirect(new URL(`${loginPath}?error=missing_code`, config.appUrl));\n }\n\n try {\n const tokenResponse = await fetch(\n `${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_EXCHANGE}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n code,\n redirectUri: `${config.appUrl}${callbackPath}?from=${from}`,\n }),\n },\n );\n\n const body = await tokenResponse.json().catch(() => null);\n\n if (!tokenResponse.ok) {\n console.error('[iam-next/callback] token exchange failed:', body);\n return NextResponse.redirect(\n new URL(`${loginPath}?error=token_exchange_failed`, config.appUrl),\n );\n }\n\n const tokens = parseTokens(body);\n if (!tokens) {\n // 200 with no/incomplete tokens — IAM-side bug. Don't redirect\n // to /auth/complete with `undefined` in the hash.\n console.error('[iam-next/callback] token exchange returned 200 with missing fields:', body);\n return NextResponse.redirect(\n new URL(`${loginPath}?error=token_exchange_invalid`, config.appUrl),\n );\n }\n\n const redirectUrl = new URL(completePath, config.appUrl);\n redirectUrl.hash = `access_token=${tokens.access_token}&expires_in=${tokens.expires_in}`;\n\n const response = NextResponse.redirect(redirectUrl);\n setRefreshCookie(response, config, tokens.refresh_token);\n setAuthOriginCookie(response, config, from);\n return response;\n } catch (error) {\n console.error('[iam-next/callback] unexpected error:', error);\n return NextResponse.redirect(new URL(`${loginPath}?error=server_error`, config.appUrl));\n }\n };\n}\n","import { NextResponse, type NextRequest } from 'next/server';\nimport { IAM_ENDPOINTS } from '@drvalue-oss/iam-core';\nimport { resolveConfig, type IamNextConfig } from '../config.js';\nimport { clearRefreshCookie, setRefreshCookie } from '../cookies.js';\n\nexport type CreateRefreshHandlerOptions = IamNextConfig;\n\ninterface ParsedTokens {\n access_token: string;\n refresh_token: string;\n expires_in: number;\n}\n\nfunction parseTokens(raw: unknown): ParsedTokens | null {\n if (!raw || typeof raw !== 'object') return null;\n const r = raw as Record<string, unknown>;\n if (\n typeof r.access_token !== 'string' ||\n typeof r.refresh_token !== 'string'\n ) {\n return null;\n }\n return {\n access_token: r.access_token,\n refresh_token: r.refresh_token,\n expires_in: typeof r.expires_in === 'number' ? r.expires_in : 900,\n };\n}\n\n/**\n * Factory for the POST handler at `/api/auth/refresh`.\n *\n * Reads refresh token from the httpOnly cookie, calls IAM\n * `/auth/token/refresh`, returns the new access token to the browser\n * and rotates the refresh cookie.\n *\n * Returns:\n * 200 { access_token, expires_in } → success\n * 401 { error: 'No refresh token' } → cookie missing\n * 401 { error: 'Refresh failed' } → IAM rejected OR 200 with no token fields\n * (also clears the bad cookie)\n * 500 { error: 'Server error' } → unexpected (cookie left as-is, retry-friendly)\n */\nexport function createRefreshHandler(options: CreateRefreshHandlerOptions) {\n const config = resolveConfig(options);\n\n return async function POST(request: NextRequest): Promise<NextResponse> {\n const refreshToken = request.cookies.get(config.refreshCookieName)?.value;\n if (!refreshToken) {\n return NextResponse.json({ error: 'No refresh token' }, { status: 401 });\n }\n\n try {\n const tokenResponse = await fetch(\n `${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_REFRESH}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ refresh_token: refreshToken }),\n },\n );\n\n const body = await tokenResponse.json().catch(() => null);\n const tokens = tokenResponse.ok ? parseTokens(body) : null;\n\n if (!tokens) {\n const response = NextResponse.json({ error: 'Refresh failed' }, { status: 401 });\n clearRefreshCookie(response, config);\n return response;\n }\n\n const response = NextResponse.json({\n access_token: tokens.access_token,\n expires_in: tokens.expires_in,\n });\n setRefreshCookie(response, config, tokens.refresh_token);\n return response;\n } catch (error) {\n console.error('[iam-next/refresh] unexpected error:', error);\n return NextResponse.json({ error: 'Server error' }, { status: 500 });\n }\n };\n}\n","import { NextResponse, type NextRequest } from 'next/server';\nimport { IAM_ENDPOINTS } from '@drvalue-oss/iam-core';\nimport { resolveConfig, type IamNextConfig } from '../config.js';\nimport { clearRefreshCookie } from '../cookies.js';\n\nexport type CreateLogoutHandlerOptions = IamNextConfig;\n\n/**\n * Factory for the POST handler at `/api/auth/logout`.\n *\n * Two responsibilities:\n * 1. Best-effort: tell IAM to revoke all refresh tokens for this\n * user (so other tabs/devices are logged out too).\n * 2. Always: clear the refresh-token cookie on THIS app.\n *\n * The IAM call is best-effort because we want logout to feel\n * instant. If IAM is briefly down, the cookie is still cleared and\n * the access token expires within minutes anyway.\n *\n * To extract `user_id`, we decode the JWT payload of the refresh\n * token without verifying. Same trust model as `iam-core/decodeJwtPayload`\n * — only used to address the revoke call, not to make decisions.\n */\nexport function createLogoutHandler(options: CreateLogoutHandlerOptions) {\n const config = resolveConfig(options);\n\n return async function POST(request: NextRequest): Promise<NextResponse> {\n const refreshToken = request.cookies.get(config.refreshCookieName)?.value;\n\n if (refreshToken && refreshToken.includes('.')) {\n try {\n const payload = JSON.parse(\n Buffer.from(refreshToken.split('.')[1] ?? '', 'base64url').toString(),\n ) as { sub?: string };\n if (payload.sub) {\n await fetch(`${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_REVOKE}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...(config.internalApiKey\n ? { 'X-Internal-Api-Key': config.internalApiKey }\n : {}),\n },\n body: JSON.stringify({ user_id: payload.sub }),\n });\n }\n } catch (error) {\n console.error('[iam-next/logout] IAM token revoke failed (best-effort):', error);\n }\n }\n\n const response = NextResponse.json({ success: true });\n clearRefreshCookie(response, config);\n return response;\n };\n}\n"]}
@@ -0,0 +1,115 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ export { IAM_ENDPOINTS, buildLoginUrl, buildRegisterUrl } from '@drvalue-oss/iam-core';
3
+
4
+ interface IamNextConfig {
5
+ /** Base URL of your IAM server. Required. e.g. `https://iam.drvalue.co.kr` */
6
+ iamServerUrl: string;
7
+ /** Base URL of THIS Next.js app. Required. e.g. `https://app.drvalue.co.kr` */
8
+ appUrl: string;
9
+ /**
10
+ * Domain for the refresh-token cookie. Set to e.g. `.drvalue.co.kr`
11
+ * to share the cookie across sub-apps. Leave undefined for
12
+ * single-host deployments.
13
+ */
14
+ cookieDomain?: string;
15
+ /**
16
+ * Cookie name for the httpOnly refresh token. Defaults to
17
+ * `STORAGE_KEYS.REFRESH_TOKEN`. Override when two drvalue apps
18
+ * share an origin but should NOT share refresh tokens.
19
+ */
20
+ refreshCookieName?: string;
21
+ /**
22
+ * Cookie name for the short-lived `auth_origin` marker. Defaults to
23
+ * `STORAGE_KEYS.AUTH_ORIGIN`.
24
+ */
25
+ authOriginCookieName?: string;
26
+ /** Refresh-token cookie max age in seconds. Defaults to 7 days. */
27
+ refreshCookieMaxAgeSeconds?: number;
28
+ /**
29
+ * Internal API key forwarded to IAM `/auth/token/revoke` so the
30
+ * server can authorize cross-service token revocation. Optional.
31
+ */
32
+ internalApiKey?: string;
33
+ }
34
+ interface ResolvedIamNextConfig extends Required<Omit<IamNextConfig, 'cookieDomain' | 'internalApiKey'>> {
35
+ cookieDomain?: string;
36
+ internalApiKey?: string;
37
+ }
38
+
39
+ interface CreateCallbackHandlerOptions extends IamNextConfig {
40
+ /**
41
+ * Path on this app where the callback handler lives. Used to
42
+ * reconstruct `redirectUri` for the token exchange. Defaults to
43
+ * `/auth/callback`.
44
+ */
45
+ callbackPath?: string;
46
+ /**
47
+ * Path the browser is redirected to after a successful token
48
+ * exchange. The access token is appended in the URL hash so it
49
+ * never hits the server log. Defaults to `/auth/complete`.
50
+ */
51
+ completePath?: string;
52
+ /**
53
+ * Map an `auth_origin` value (read from `?from=...`) to the login
54
+ * path used when the exchange fails. Default returns `/login`.
55
+ */
56
+ resolveLoginPath?: (origin: string) => string;
57
+ }
58
+ /**
59
+ * Factory for the GET handler at `/auth/callback`.
60
+ *
61
+ * Flow:
62
+ * IAM → ?code=xxx&from=user
63
+ * → exchange code for tokens via IAM `/auth/token/exchange`
64
+ * → set refresh_token httpOnly cookie
65
+ * → set short-lived auth_origin cookie
66
+ * → 302 to /auth/complete#access_token=...&expires_in=...
67
+ *
68
+ * Why the hash: access token never lands in the Next.js server log
69
+ * or the Referer header of downstream navigations.
70
+ *
71
+ * Why we re-validate the response body even on 2xx: IAM has historically
72
+ * had bugs where it returns HTTP 200 with `{"error": "..."}` and no
73
+ * `access_token` field. If we let those through we'd 302 the browser
74
+ * to `/auth/complete#access_token=undefined`, which then triggers the
75
+ * SPA's refresh loop indefinitely. Treat missing fields as a hard fail.
76
+ */
77
+ declare function createCallbackHandler(options: CreateCallbackHandlerOptions): (request: NextRequest) => Promise<NextResponse>;
78
+
79
+ type CreateRefreshHandlerOptions = IamNextConfig;
80
+ /**
81
+ * Factory for the POST handler at `/api/auth/refresh`.
82
+ *
83
+ * Reads refresh token from the httpOnly cookie, calls IAM
84
+ * `/auth/token/refresh`, returns the new access token to the browser
85
+ * and rotates the refresh cookie.
86
+ *
87
+ * Returns:
88
+ * 200 { access_token, expires_in } → success
89
+ * 401 { error: 'No refresh token' } → cookie missing
90
+ * 401 { error: 'Refresh failed' } → IAM rejected OR 200 with no token fields
91
+ * (also clears the bad cookie)
92
+ * 500 { error: 'Server error' } → unexpected (cookie left as-is, retry-friendly)
93
+ */
94
+ declare function createRefreshHandler(options: CreateRefreshHandlerOptions): (request: NextRequest) => Promise<NextResponse>;
95
+
96
+ type CreateLogoutHandlerOptions = IamNextConfig;
97
+ /**
98
+ * Factory for the POST handler at `/api/auth/logout`.
99
+ *
100
+ * Two responsibilities:
101
+ * 1. Best-effort: tell IAM to revoke all refresh tokens for this
102
+ * user (so other tabs/devices are logged out too).
103
+ * 2. Always: clear the refresh-token cookie on THIS app.
104
+ *
105
+ * The IAM call is best-effort because we want logout to feel
106
+ * instant. If IAM is briefly down, the cookie is still cleared and
107
+ * the access token expires within minutes anyway.
108
+ *
109
+ * To extract `user_id`, we decode the JWT payload of the refresh
110
+ * token without verifying. Same trust model as `iam-core/decodeJwtPayload`
111
+ * — only used to address the revoke call, not to make decisions.
112
+ */
113
+ declare function createLogoutHandler(options: CreateLogoutHandlerOptions): (request: NextRequest) => Promise<NextResponse>;
114
+
115
+ export { type CreateCallbackHandlerOptions, type CreateLogoutHandlerOptions, type CreateRefreshHandlerOptions, type IamNextConfig, type ResolvedIamNextConfig, createCallbackHandler, createLogoutHandler, createRefreshHandler };
@@ -0,0 +1,115 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ export { IAM_ENDPOINTS, buildLoginUrl, buildRegisterUrl } from '@drvalue-oss/iam-core';
3
+
4
+ interface IamNextConfig {
5
+ /** Base URL of your IAM server. Required. e.g. `https://iam.drvalue.co.kr` */
6
+ iamServerUrl: string;
7
+ /** Base URL of THIS Next.js app. Required. e.g. `https://app.drvalue.co.kr` */
8
+ appUrl: string;
9
+ /**
10
+ * Domain for the refresh-token cookie. Set to e.g. `.drvalue.co.kr`
11
+ * to share the cookie across sub-apps. Leave undefined for
12
+ * single-host deployments.
13
+ */
14
+ cookieDomain?: string;
15
+ /**
16
+ * Cookie name for the httpOnly refresh token. Defaults to
17
+ * `STORAGE_KEYS.REFRESH_TOKEN`. Override when two drvalue apps
18
+ * share an origin but should NOT share refresh tokens.
19
+ */
20
+ refreshCookieName?: string;
21
+ /**
22
+ * Cookie name for the short-lived `auth_origin` marker. Defaults to
23
+ * `STORAGE_KEYS.AUTH_ORIGIN`.
24
+ */
25
+ authOriginCookieName?: string;
26
+ /** Refresh-token cookie max age in seconds. Defaults to 7 days. */
27
+ refreshCookieMaxAgeSeconds?: number;
28
+ /**
29
+ * Internal API key forwarded to IAM `/auth/token/revoke` so the
30
+ * server can authorize cross-service token revocation. Optional.
31
+ */
32
+ internalApiKey?: string;
33
+ }
34
+ interface ResolvedIamNextConfig extends Required<Omit<IamNextConfig, 'cookieDomain' | 'internalApiKey'>> {
35
+ cookieDomain?: string;
36
+ internalApiKey?: string;
37
+ }
38
+
39
+ interface CreateCallbackHandlerOptions extends IamNextConfig {
40
+ /**
41
+ * Path on this app where the callback handler lives. Used to
42
+ * reconstruct `redirectUri` for the token exchange. Defaults to
43
+ * `/auth/callback`.
44
+ */
45
+ callbackPath?: string;
46
+ /**
47
+ * Path the browser is redirected to after a successful token
48
+ * exchange. The access token is appended in the URL hash so it
49
+ * never hits the server log. Defaults to `/auth/complete`.
50
+ */
51
+ completePath?: string;
52
+ /**
53
+ * Map an `auth_origin` value (read from `?from=...`) to the login
54
+ * path used when the exchange fails. Default returns `/login`.
55
+ */
56
+ resolveLoginPath?: (origin: string) => string;
57
+ }
58
+ /**
59
+ * Factory for the GET handler at `/auth/callback`.
60
+ *
61
+ * Flow:
62
+ * IAM → ?code=xxx&from=user
63
+ * → exchange code for tokens via IAM `/auth/token/exchange`
64
+ * → set refresh_token httpOnly cookie
65
+ * → set short-lived auth_origin cookie
66
+ * → 302 to /auth/complete#access_token=...&expires_in=...
67
+ *
68
+ * Why the hash: access token never lands in the Next.js server log
69
+ * or the Referer header of downstream navigations.
70
+ *
71
+ * Why we re-validate the response body even on 2xx: IAM has historically
72
+ * had bugs where it returns HTTP 200 with `{"error": "..."}` and no
73
+ * `access_token` field. If we let those through we'd 302 the browser
74
+ * to `/auth/complete#access_token=undefined`, which then triggers the
75
+ * SPA's refresh loop indefinitely. Treat missing fields as a hard fail.
76
+ */
77
+ declare function createCallbackHandler(options: CreateCallbackHandlerOptions): (request: NextRequest) => Promise<NextResponse>;
78
+
79
+ type CreateRefreshHandlerOptions = IamNextConfig;
80
+ /**
81
+ * Factory for the POST handler at `/api/auth/refresh`.
82
+ *
83
+ * Reads refresh token from the httpOnly cookie, calls IAM
84
+ * `/auth/token/refresh`, returns the new access token to the browser
85
+ * and rotates the refresh cookie.
86
+ *
87
+ * Returns:
88
+ * 200 { access_token, expires_in } → success
89
+ * 401 { error: 'No refresh token' } → cookie missing
90
+ * 401 { error: 'Refresh failed' } → IAM rejected OR 200 with no token fields
91
+ * (also clears the bad cookie)
92
+ * 500 { error: 'Server error' } → unexpected (cookie left as-is, retry-friendly)
93
+ */
94
+ declare function createRefreshHandler(options: CreateRefreshHandlerOptions): (request: NextRequest) => Promise<NextResponse>;
95
+
96
+ type CreateLogoutHandlerOptions = IamNextConfig;
97
+ /**
98
+ * Factory for the POST handler at `/api/auth/logout`.
99
+ *
100
+ * Two responsibilities:
101
+ * 1. Best-effort: tell IAM to revoke all refresh tokens for this
102
+ * user (so other tabs/devices are logged out too).
103
+ * 2. Always: clear the refresh-token cookie on THIS app.
104
+ *
105
+ * The IAM call is best-effort because we want logout to feel
106
+ * instant. If IAM is briefly down, the cookie is still cleared and
107
+ * the access token expires within minutes anyway.
108
+ *
109
+ * To extract `user_id`, we decode the JWT payload of the refresh
110
+ * token without verifying. Same trust model as `iam-core/decodeJwtPayload`
111
+ * — only used to address the revoke call, not to make decisions.
112
+ */
113
+ declare function createLogoutHandler(options: CreateLogoutHandlerOptions): (request: NextRequest) => Promise<NextResponse>;
114
+
115
+ export { type CreateCallbackHandlerOptions, type CreateLogoutHandlerOptions, type CreateRefreshHandlerOptions, type IamNextConfig, type ResolvedIamNextConfig, createCallbackHandler, createLogoutHandler, createRefreshHandler };
package/dist/index.js ADDED
@@ -0,0 +1,193 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { IAM_ENDPOINTS, STORAGE_KEYS } from '@drvalue-oss/iam-core';
3
+ export { IAM_ENDPOINTS, buildLoginUrl, buildRegisterUrl } from '@drvalue-oss/iam-core';
4
+
5
+ // src/handlers/callback.ts
6
+ function resolveConfig(input) {
7
+ if (!input.iamServerUrl) throw new Error("iam-next: iamServerUrl is required");
8
+ if (!input.appUrl) throw new Error("iam-next: appUrl is required");
9
+ return {
10
+ iamServerUrl: input.iamServerUrl.replace(/\/$/, ""),
11
+ appUrl: input.appUrl.replace(/\/$/, ""),
12
+ cookieDomain: input.cookieDomain,
13
+ refreshCookieName: input.refreshCookieName ?? STORAGE_KEYS.REFRESH_TOKEN,
14
+ authOriginCookieName: input.authOriginCookieName ?? STORAGE_KEYS.AUTH_ORIGIN,
15
+ refreshCookieMaxAgeSeconds: input.refreshCookieMaxAgeSeconds ?? 7 * 24 * 60 * 60,
16
+ internalApiKey: input.internalApiKey
17
+ };
18
+ }
19
+
20
+ // src/cookies.ts
21
+ function setRefreshCookie(response, config, value) {
22
+ response.cookies.set(config.refreshCookieName, value, {
23
+ httpOnly: true,
24
+ secure: process.env.NODE_ENV === "production",
25
+ sameSite: "lax",
26
+ path: "/",
27
+ maxAge: config.refreshCookieMaxAgeSeconds,
28
+ ...config.cookieDomain ? { domain: config.cookieDomain } : {}
29
+ });
30
+ }
31
+ function clearRefreshCookie(response, config) {
32
+ response.cookies.set(config.refreshCookieName, "", {
33
+ httpOnly: true,
34
+ secure: process.env.NODE_ENV === "production",
35
+ sameSite: "lax",
36
+ path: "/",
37
+ maxAge: 0,
38
+ ...config.cookieDomain ? { domain: config.cookieDomain } : {}
39
+ });
40
+ }
41
+ function setAuthOriginCookie(response, config, value) {
42
+ response.cookies.set(config.authOriginCookieName, value, {
43
+ httpOnly: false,
44
+ secure: process.env.NODE_ENV === "production",
45
+ sameSite: "lax",
46
+ path: "/",
47
+ maxAge: 5 * 60,
48
+ ...config.cookieDomain ? { domain: config.cookieDomain } : {}
49
+ });
50
+ }
51
+
52
+ // src/handlers/callback.ts
53
+ function parseTokens(raw) {
54
+ if (!raw || typeof raw !== "object") return null;
55
+ const r = raw;
56
+ if (typeof r.access_token !== "string" || typeof r.refresh_token !== "string") {
57
+ return null;
58
+ }
59
+ return {
60
+ access_token: r.access_token,
61
+ refresh_token: r.refresh_token,
62
+ expires_in: typeof r.expires_in === "number" ? r.expires_in : 900
63
+ };
64
+ }
65
+ function createCallbackHandler(options) {
66
+ const config = resolveConfig(options);
67
+ const callbackPath = options.callbackPath ?? "/auth/callback";
68
+ const completePath = options.completePath ?? "/auth/complete";
69
+ const resolveLoginPath = options.resolveLoginPath ?? (() => "/login");
70
+ return async function GET(request) {
71
+ const code = request.nextUrl.searchParams.get("code");
72
+ const from = request.nextUrl.searchParams.get("from") ?? "user";
73
+ const loginPath = resolveLoginPath(from);
74
+ if (!code) {
75
+ return NextResponse.redirect(new URL(`${loginPath}?error=missing_code`, config.appUrl));
76
+ }
77
+ try {
78
+ const tokenResponse = await fetch(
79
+ `${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_EXCHANGE}`,
80
+ {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({
84
+ code,
85
+ redirectUri: `${config.appUrl}${callbackPath}?from=${from}`
86
+ })
87
+ }
88
+ );
89
+ const body = await tokenResponse.json().catch(() => null);
90
+ if (!tokenResponse.ok) {
91
+ console.error("[iam-next/callback] token exchange failed:", body);
92
+ return NextResponse.redirect(
93
+ new URL(`${loginPath}?error=token_exchange_failed`, config.appUrl)
94
+ );
95
+ }
96
+ const tokens = parseTokens(body);
97
+ if (!tokens) {
98
+ console.error("[iam-next/callback] token exchange returned 200 with missing fields:", body);
99
+ return NextResponse.redirect(
100
+ new URL(`${loginPath}?error=token_exchange_invalid`, config.appUrl)
101
+ );
102
+ }
103
+ const redirectUrl = new URL(completePath, config.appUrl);
104
+ redirectUrl.hash = `access_token=${tokens.access_token}&expires_in=${tokens.expires_in}`;
105
+ const response = NextResponse.redirect(redirectUrl);
106
+ setRefreshCookie(response, config, tokens.refresh_token);
107
+ setAuthOriginCookie(response, config, from);
108
+ return response;
109
+ } catch (error) {
110
+ console.error("[iam-next/callback] unexpected error:", error);
111
+ return NextResponse.redirect(new URL(`${loginPath}?error=server_error`, config.appUrl));
112
+ }
113
+ };
114
+ }
115
+ function parseTokens2(raw) {
116
+ if (!raw || typeof raw !== "object") return null;
117
+ const r = raw;
118
+ if (typeof r.access_token !== "string" || typeof r.refresh_token !== "string") {
119
+ return null;
120
+ }
121
+ return {
122
+ access_token: r.access_token,
123
+ refresh_token: r.refresh_token,
124
+ expires_in: typeof r.expires_in === "number" ? r.expires_in : 900
125
+ };
126
+ }
127
+ function createRefreshHandler(options) {
128
+ const config = resolveConfig(options);
129
+ return async function POST(request) {
130
+ const refreshToken = request.cookies.get(config.refreshCookieName)?.value;
131
+ if (!refreshToken) {
132
+ return NextResponse.json({ error: "No refresh token" }, { status: 401 });
133
+ }
134
+ try {
135
+ const tokenResponse = await fetch(
136
+ `${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_REFRESH}`,
137
+ {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify({ refresh_token: refreshToken })
141
+ }
142
+ );
143
+ const body = await tokenResponse.json().catch(() => null);
144
+ const tokens = tokenResponse.ok ? parseTokens2(body) : null;
145
+ if (!tokens) {
146
+ const response2 = NextResponse.json({ error: "Refresh failed" }, { status: 401 });
147
+ clearRefreshCookie(response2, config);
148
+ return response2;
149
+ }
150
+ const response = NextResponse.json({
151
+ access_token: tokens.access_token,
152
+ expires_in: tokens.expires_in
153
+ });
154
+ setRefreshCookie(response, config, tokens.refresh_token);
155
+ return response;
156
+ } catch (error) {
157
+ console.error("[iam-next/refresh] unexpected error:", error);
158
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
159
+ }
160
+ };
161
+ }
162
+ function createLogoutHandler(options) {
163
+ const config = resolveConfig(options);
164
+ return async function POST(request) {
165
+ const refreshToken = request.cookies.get(config.refreshCookieName)?.value;
166
+ if (refreshToken && refreshToken.includes(".")) {
167
+ try {
168
+ const payload = JSON.parse(
169
+ Buffer.from(refreshToken.split(".")[1] ?? "", "base64url").toString()
170
+ );
171
+ if (payload.sub) {
172
+ await fetch(`${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_REVOKE}`, {
173
+ method: "POST",
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ ...config.internalApiKey ? { "X-Internal-Api-Key": config.internalApiKey } : {}
177
+ },
178
+ body: JSON.stringify({ user_id: payload.sub })
179
+ });
180
+ }
181
+ } catch (error) {
182
+ console.error("[iam-next/logout] IAM token revoke failed (best-effort):", error);
183
+ }
184
+ }
185
+ const response = NextResponse.json({ success: true });
186
+ clearRefreshCookie(response, config);
187
+ return response;
188
+ };
189
+ }
190
+
191
+ export { createCallbackHandler, createLogoutHandler, createRefreshHandler };
192
+ //# sourceMappingURL=index.js.map
193
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts","../src/cookies.ts","../src/handlers/callback.ts","../src/handlers/refresh.ts","../src/handlers/logout.ts"],"names":["parseTokens","NextResponse","IAM_ENDPOINTS","response"],"mappings":"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","file":"index.js","sourcesContent":["import { STORAGE_KEYS } from '@drvalue-oss/iam-core';\n\nexport interface IamNextConfig {\n /** Base URL of your IAM server. Required. e.g. `https://iam.drvalue.co.kr` */\n iamServerUrl: string;\n /** Base URL of THIS Next.js app. Required. e.g. `https://app.drvalue.co.kr` */\n appUrl: string;\n /**\n * Domain for the refresh-token cookie. Set to e.g. `.drvalue.co.kr`\n * to share the cookie across sub-apps. Leave undefined for\n * single-host deployments.\n */\n cookieDomain?: string;\n /**\n * Cookie name for the httpOnly refresh token. Defaults to\n * `STORAGE_KEYS.REFRESH_TOKEN`. Override when two drvalue apps\n * share an origin but should NOT share refresh tokens.\n */\n refreshCookieName?: string;\n /**\n * Cookie name for the short-lived `auth_origin` marker. Defaults to\n * `STORAGE_KEYS.AUTH_ORIGIN`.\n */\n authOriginCookieName?: string;\n /** Refresh-token cookie max age in seconds. Defaults to 7 days. */\n refreshCookieMaxAgeSeconds?: number;\n /**\n * Internal API key forwarded to IAM `/auth/token/revoke` so the\n * server can authorize cross-service token revocation. Optional.\n */\n internalApiKey?: string;\n}\n\nexport interface ResolvedIamNextConfig extends Required<Omit<IamNextConfig, 'cookieDomain' | 'internalApiKey'>> {\n cookieDomain?: string;\n internalApiKey?: string;\n}\n\nexport function resolveConfig(input: IamNextConfig): ResolvedIamNextConfig {\n if (!input.iamServerUrl) throw new Error('iam-next: iamServerUrl is required');\n if (!input.appUrl) throw new Error('iam-next: appUrl is required');\n return {\n iamServerUrl: input.iamServerUrl.replace(/\\/$/, ''),\n appUrl: input.appUrl.replace(/\\/$/, ''),\n cookieDomain: input.cookieDomain,\n refreshCookieName: input.refreshCookieName ?? STORAGE_KEYS.REFRESH_TOKEN,\n authOriginCookieName: input.authOriginCookieName ?? STORAGE_KEYS.AUTH_ORIGIN,\n refreshCookieMaxAgeSeconds: input.refreshCookieMaxAgeSeconds ?? 7 * 24 * 60 * 60,\n internalApiKey: input.internalApiKey,\n };\n}\n","import type { NextResponse } from 'next/server';\nimport type { ResolvedIamNextConfig } from './config.js';\n\nexport function setRefreshCookie(\n response: NextResponse,\n config: ResolvedIamNextConfig,\n value: string,\n): void {\n response.cookies.set(config.refreshCookieName, value, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: config.refreshCookieMaxAgeSeconds,\n ...(config.cookieDomain ? { domain: config.cookieDomain } : {}),\n });\n}\n\nexport function clearRefreshCookie(\n response: NextResponse,\n config: ResolvedIamNextConfig,\n): void {\n response.cookies.set(config.refreshCookieName, '', {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: 0,\n ...(config.cookieDomain ? { domain: config.cookieDomain } : {}),\n });\n}\n\n/**\n * Short-lived (5 min), client-readable. Tells the /auth/complete page\n * where to redirect after token handoff (e.g. user vs admin dashboard).\n */\nexport function setAuthOriginCookie(\n response: NextResponse,\n config: ResolvedIamNextConfig,\n value: string,\n): void {\n response.cookies.set(config.authOriginCookieName, value, {\n httpOnly: false,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: 5 * 60,\n ...(config.cookieDomain ? { domain: config.cookieDomain } : {}),\n });\n}\n","import { NextResponse, type NextRequest } from 'next/server';\nimport { IAM_ENDPOINTS } from '@drvalue-oss/iam-core';\nimport { resolveConfig, type IamNextConfig } from '../config.js';\nimport { setAuthOriginCookie, setRefreshCookie } from '../cookies.js';\n\nexport interface CreateCallbackHandlerOptions extends IamNextConfig {\n /**\n * Path on this app where the callback handler lives. Used to\n * reconstruct `redirectUri` for the token exchange. Defaults to\n * `/auth/callback`.\n */\n callbackPath?: string;\n /**\n * Path the browser is redirected to after a successful token\n * exchange. The access token is appended in the URL hash so it\n * never hits the server log. Defaults to `/auth/complete`.\n */\n completePath?: string;\n /**\n * Map an `auth_origin` value (read from `?from=...`) to the login\n * path used when the exchange fails. Default returns `/login`.\n */\n resolveLoginPath?: (origin: string) => string;\n}\n\ninterface ParsedTokens {\n access_token: string;\n refresh_token: string;\n expires_in: number;\n}\n\nfunction parseTokens(raw: unknown): ParsedTokens | null {\n if (!raw || typeof raw !== 'object') return null;\n const r = raw as Record<string, unknown>;\n if (\n typeof r.access_token !== 'string' ||\n typeof r.refresh_token !== 'string'\n ) {\n return null;\n }\n return {\n access_token: r.access_token,\n refresh_token: r.refresh_token,\n expires_in: typeof r.expires_in === 'number' ? r.expires_in : 900,\n };\n}\n\n/**\n * Factory for the GET handler at `/auth/callback`.\n *\n * Flow:\n * IAM → ?code=xxx&from=user\n * → exchange code for tokens via IAM `/auth/token/exchange`\n * → set refresh_token httpOnly cookie\n * → set short-lived auth_origin cookie\n * → 302 to /auth/complete#access_token=...&expires_in=...\n *\n * Why the hash: access token never lands in the Next.js server log\n * or the Referer header of downstream navigations.\n *\n * Why we re-validate the response body even on 2xx: IAM has historically\n * had bugs where it returns HTTP 200 with `{\"error\": \"...\"}` and no\n * `access_token` field. If we let those through we'd 302 the browser\n * to `/auth/complete#access_token=undefined`, which then triggers the\n * SPA's refresh loop indefinitely. Treat missing fields as a hard fail.\n */\nexport function createCallbackHandler(options: CreateCallbackHandlerOptions) {\n const config = resolveConfig(options);\n const callbackPath = options.callbackPath ?? '/auth/callback';\n const completePath = options.completePath ?? '/auth/complete';\n const resolveLoginPath = options.resolveLoginPath ?? (() => '/login');\n\n return async function GET(request: NextRequest): Promise<NextResponse> {\n const code = request.nextUrl.searchParams.get('code');\n const from = request.nextUrl.searchParams.get('from') ?? 'user';\n const loginPath = resolveLoginPath(from);\n\n if (!code) {\n return NextResponse.redirect(new URL(`${loginPath}?error=missing_code`, config.appUrl));\n }\n\n try {\n const tokenResponse = await fetch(\n `${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_EXCHANGE}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n code,\n redirectUri: `${config.appUrl}${callbackPath}?from=${from}`,\n }),\n },\n );\n\n const body = await tokenResponse.json().catch(() => null);\n\n if (!tokenResponse.ok) {\n console.error('[iam-next/callback] token exchange failed:', body);\n return NextResponse.redirect(\n new URL(`${loginPath}?error=token_exchange_failed`, config.appUrl),\n );\n }\n\n const tokens = parseTokens(body);\n if (!tokens) {\n // 200 with no/incomplete tokens — IAM-side bug. Don't redirect\n // to /auth/complete with `undefined` in the hash.\n console.error('[iam-next/callback] token exchange returned 200 with missing fields:', body);\n return NextResponse.redirect(\n new URL(`${loginPath}?error=token_exchange_invalid`, config.appUrl),\n );\n }\n\n const redirectUrl = new URL(completePath, config.appUrl);\n redirectUrl.hash = `access_token=${tokens.access_token}&expires_in=${tokens.expires_in}`;\n\n const response = NextResponse.redirect(redirectUrl);\n setRefreshCookie(response, config, tokens.refresh_token);\n setAuthOriginCookie(response, config, from);\n return response;\n } catch (error) {\n console.error('[iam-next/callback] unexpected error:', error);\n return NextResponse.redirect(new URL(`${loginPath}?error=server_error`, config.appUrl));\n }\n };\n}\n","import { NextResponse, type NextRequest } from 'next/server';\nimport { IAM_ENDPOINTS } from '@drvalue-oss/iam-core';\nimport { resolveConfig, type IamNextConfig } from '../config.js';\nimport { clearRefreshCookie, setRefreshCookie } from '../cookies.js';\n\nexport type CreateRefreshHandlerOptions = IamNextConfig;\n\ninterface ParsedTokens {\n access_token: string;\n refresh_token: string;\n expires_in: number;\n}\n\nfunction parseTokens(raw: unknown): ParsedTokens | null {\n if (!raw || typeof raw !== 'object') return null;\n const r = raw as Record<string, unknown>;\n if (\n typeof r.access_token !== 'string' ||\n typeof r.refresh_token !== 'string'\n ) {\n return null;\n }\n return {\n access_token: r.access_token,\n refresh_token: r.refresh_token,\n expires_in: typeof r.expires_in === 'number' ? r.expires_in : 900,\n };\n}\n\n/**\n * Factory for the POST handler at `/api/auth/refresh`.\n *\n * Reads refresh token from the httpOnly cookie, calls IAM\n * `/auth/token/refresh`, returns the new access token to the browser\n * and rotates the refresh cookie.\n *\n * Returns:\n * 200 { access_token, expires_in } → success\n * 401 { error: 'No refresh token' } → cookie missing\n * 401 { error: 'Refresh failed' } → IAM rejected OR 200 with no token fields\n * (also clears the bad cookie)\n * 500 { error: 'Server error' } → unexpected (cookie left as-is, retry-friendly)\n */\nexport function createRefreshHandler(options: CreateRefreshHandlerOptions) {\n const config = resolveConfig(options);\n\n return async function POST(request: NextRequest): Promise<NextResponse> {\n const refreshToken = request.cookies.get(config.refreshCookieName)?.value;\n if (!refreshToken) {\n return NextResponse.json({ error: 'No refresh token' }, { status: 401 });\n }\n\n try {\n const tokenResponse = await fetch(\n `${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_REFRESH}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ refresh_token: refreshToken }),\n },\n );\n\n const body = await tokenResponse.json().catch(() => null);\n const tokens = tokenResponse.ok ? parseTokens(body) : null;\n\n if (!tokens) {\n const response = NextResponse.json({ error: 'Refresh failed' }, { status: 401 });\n clearRefreshCookie(response, config);\n return response;\n }\n\n const response = NextResponse.json({\n access_token: tokens.access_token,\n expires_in: tokens.expires_in,\n });\n setRefreshCookie(response, config, tokens.refresh_token);\n return response;\n } catch (error) {\n console.error('[iam-next/refresh] unexpected error:', error);\n return NextResponse.json({ error: 'Server error' }, { status: 500 });\n }\n };\n}\n","import { NextResponse, type NextRequest } from 'next/server';\nimport { IAM_ENDPOINTS } from '@drvalue-oss/iam-core';\nimport { resolveConfig, type IamNextConfig } from '../config.js';\nimport { clearRefreshCookie } from '../cookies.js';\n\nexport type CreateLogoutHandlerOptions = IamNextConfig;\n\n/**\n * Factory for the POST handler at `/api/auth/logout`.\n *\n * Two responsibilities:\n * 1. Best-effort: tell IAM to revoke all refresh tokens for this\n * user (so other tabs/devices are logged out too).\n * 2. Always: clear the refresh-token cookie on THIS app.\n *\n * The IAM call is best-effort because we want logout to feel\n * instant. If IAM is briefly down, the cookie is still cleared and\n * the access token expires within minutes anyway.\n *\n * To extract `user_id`, we decode the JWT payload of the refresh\n * token without verifying. Same trust model as `iam-core/decodeJwtPayload`\n * — only used to address the revoke call, not to make decisions.\n */\nexport function createLogoutHandler(options: CreateLogoutHandlerOptions) {\n const config = resolveConfig(options);\n\n return async function POST(request: NextRequest): Promise<NextResponse> {\n const refreshToken = request.cookies.get(config.refreshCookieName)?.value;\n\n if (refreshToken && refreshToken.includes('.')) {\n try {\n const payload = JSON.parse(\n Buffer.from(refreshToken.split('.')[1] ?? '', 'base64url').toString(),\n ) as { sub?: string };\n if (payload.sub) {\n await fetch(`${config.iamServerUrl}${IAM_ENDPOINTS.TOKEN_REVOKE}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...(config.internalApiKey\n ? { 'X-Internal-Api-Key': config.internalApiKey }\n : {}),\n },\n body: JSON.stringify({ user_id: payload.sub }),\n });\n }\n } catch (error) {\n console.error('[iam-next/logout] IAM token revoke failed (best-effort):', error);\n }\n }\n\n const response = NextResponse.json({ success: true });\n clearRefreshCookie(response, config);\n return response;\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@drvalue-oss/iam-next",
3
+ "version": "0.1.0",
4
+ "description": "Next.js Route Handler factories for the drvalue IAM BFF token-exchange pattern (callback / refresh / logout)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/drvalue/drvalue-iam-js",
9
+ "directory": "packages/iam-next"
10
+ },
11
+ "homepage": "https://github.com/drvalue/drvalue-iam-js/tree/main/packages/iam-next#readme",
12
+ "type": "module",
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "require": "./dist/index.cjs"
21
+ }
22
+ },
23
+ "files": ["dist", "README.md", "LICENSE"],
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "dev": "tsup --watch",
27
+ "clean": "rm -rf dist",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "sideEffects": false,
35
+ "keywords": ["drvalue", "iam", "next", "nextjs", "bff", "auth"],
36
+ "dependencies": {
37
+ "@drvalue-oss/iam-core": "workspace:*"
38
+ },
39
+ "peerDependencies": {
40
+ "next": "^14.0.0 || ^15.0.0 || ^16.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "next": "^16.0.0",
44
+ "react": "^19.0.0",
45
+ "tsup": "^8.3.5",
46
+ "typescript": "^5.7.2"
47
+ },
48
+ "engines": {
49
+ "node": ">=20.19"
50
+ }
51
+ }