@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 +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +208 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +115 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +193 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|