@wegooli/identity-delegation 0.2.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/dist/index.mjs ADDED
@@ -0,0 +1,324 @@
1
+ // src/errors.ts
2
+ var DelegationError = class extends Error {
3
+ constructor(status, code, message, details) {
4
+ super(message);
5
+ this.name = "DelegationError";
6
+ this.status = status;
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ /**
11
+ * 그 사람이 껐던 위임을 다시 만들려 했다는 뜻이다.
12
+ *
13
+ * 이건 오류가 아니라 **설계**다. 사람이 끈 위임을 백엔드가 조용히 다시 만들면
14
+ * 그 사람이 누른 스위치는 아무것도 안 한 게 된다. 다시 켜려면 화면에서 사람이
15
+ * 다시 동의를 누르고, 그 동의를 근거로 `reconsent: true` 를 보내야 한다.
16
+ */
17
+ get isRevokedByPrincipal() {
18
+ return this.code === "grant_revoked";
19
+ }
20
+ /** 진술이 거절됐다 — 서명·수신자·수명·발급자 중 하나가 조건을 못 맞췄다 */
21
+ get isAssertionRejected() {
22
+ return this.code.startsWith("assertion_") || this.code === "issuer_not_trusted";
23
+ }
24
+ };
25
+ var TokenRejected = class extends Error {
26
+ constructor(reason, revoked = false) {
27
+ super(`token rejected: ${reason}`);
28
+ this.name = "TokenRejected";
29
+ this.reason = reason;
30
+ this.revoked = revoked;
31
+ }
32
+ };
33
+
34
+ // src/client.ts
35
+ var DelegationClient = class {
36
+ constructor(options) {
37
+ // ── 위임 ────────────────────────────────────────────────────────────────
38
+ this.grants = {
39
+ /**
40
+ * 사람이 스위치를 켰을 때 부른다. **같은 (에이전트, 사람) 조합으로 여러 번
41
+ * 불러도 위임장이 하나만 생긴다** — 사람은 스위치를 두 번 누르고, 위임장이
42
+ * 둘이면 끌 때 하나만 꺼서는 안 멈춘다.
43
+ *
44
+ * 외부 주체(자체 인증 고객의 직원)라면 **이 호출이 갱신도 겸한다.** 새
45
+ * 진술을 붙여 다시 부르면 만료일이 밀린다. 진술을 못 만들게 되면(퇴사)
46
+ * 아무도 아무것도 안 해도 위임장이 스스로 닫힌다.
47
+ */
48
+ // `async` 인 것이 중요하다. 인자 검사를 동기로 던지면
49
+ // `ensure(...).catch(handle)` 로 쓴 코드가 그 오류만 못 받는다 — 같은 함수가
50
+ // 어떤 실패는 던지고 어떤 실패는 거절하는 API 는 반드시 한쪽을 놓치게 한다.
51
+ ensure: async (options) => {
52
+ const hasId = Boolean(options.principalId);
53
+ const hasAssertion = Boolean(options.principalAssertion);
54
+ if (hasId === hasAssertion) {
55
+ throw new Error(
56
+ "principalId (\uC6B0\uB9AC \uB85C\uADF8\uC778 \uC0AC\uC6A9\uC790) \uB610\uB294 principalAssertion (\uACE0\uAC1D\uC0AC\uAC00 \uC11C\uBA85\uD55C \uC9C4\uC220) \uC911 \uC815\uD655\uD788 \uD558\uB098\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4"
57
+ );
58
+ }
59
+ return this.request("POST", "/v1/agent-grants", {
60
+ agentId: options.agentId,
61
+ principalId: options.principalId,
62
+ principalAssertion: options.principalAssertion,
63
+ scope: options.scope,
64
+ allowedAudiences: Array.isArray(options.audience) ? options.audience : [options.audience],
65
+ expiresInDays: options.expiresInDays,
66
+ expiresAt: options.expiresAt,
67
+ consentEvidence: options.consentEvidence,
68
+ reconsent: options.reconsent,
69
+ authorizationDetails: options.authorizationDetails
70
+ });
71
+ },
72
+ /** 이 에이전트가 들고 있는 위임장들. 끄기 화면이 그리는 목록이다. */
73
+ list: async (options) => {
74
+ const out = await this.request(
75
+ "GET",
76
+ `/v1/agent-grants?agentId=${encodeURIComponent(options.agentId)}`
77
+ );
78
+ return out.grants ?? [];
79
+ },
80
+ /**
81
+ * 끈다. **다음 검증 시점에 바로** 먹는다 — 이미 나가 있는 토큰도 무효가 된다.
82
+ *
83
+ * 이유를 적어 두면 나중에 "왜 멈췄나" 에 답할 수 있다. 사람이 껐는지,
84
+ * 관리자가 껐는지, 사고 대응이었는지가 전부 다른 이야기다.
85
+ */
86
+ revoke: (grantId, reason) => this.request("POST", `/v1/agent-grants/${encodeURIComponent(grantId)}/revoke`, {
87
+ reason
88
+ })
89
+ };
90
+ // ── 외부 주체 ────────────────────────────────────────────────────────────
91
+ this.externalPrincipals = {
92
+ /** 진술로 알게 된 사람들. 우리 로그인 사용자가 아니다. */
93
+ list: async () => {
94
+ const out = await this.request(
95
+ "GET",
96
+ "/v1/external-principals"
97
+ );
98
+ return out.externalPrincipals ?? [];
99
+ }
100
+ };
101
+ // ── 내 서비스의 권한 어휘 ────────────────────────────────────────────────
102
+ this.resourceServers = {
103
+ /**
104
+ * 내 서비스가 받는 권한 이름과 그 뜻을 선언한다. 동의 화면이 이 문장을
105
+ * 그대로 그린다.
106
+ *
107
+ * 배포할 때마다 불러도 된다 — 주소가 같으면 행이 하나로 유지되고, 선언은
108
+ * 통째로 바뀐다. 권한 하나를 목록에서 빼면 그 권한은 더 이상 위임될 수 없다.
109
+ */
110
+ declare: async (options) => {
111
+ const out = await this.request(
112
+ "PUT",
113
+ "/v1/resource-servers",
114
+ options
115
+ );
116
+ return out.resourceServer;
117
+ },
118
+ list: async () => {
119
+ const out = await this.request(
120
+ "GET",
121
+ "/v1/resource-servers"
122
+ );
123
+ return out.resourceServers ?? [];
124
+ }
125
+ };
126
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
127
+ throw new Error(
128
+ "@wegooli/identity-delegation \uC740 \uC11C\uBC84\uC5D0\uC11C\uB9CC \uC501\uB2C8\uB2E4. sk_ \uBE44\uBC00 \uD0A4\uAC00 \uBE0C\uB77C\uC6B0\uC800\uB85C \uB098\uAC00\uBA74 \uC774 \uC870\uC9C1\uC758 \uBAA8\uB4E0 \uC704\uC784\uC744 \uB204\uAD6C\uB098 \uB9CC\uB4E4\uACE0 \uC9C0\uC6B8 \uC218 \uC788\uC2B5\uB2C8\uB2E4."
129
+ );
130
+ }
131
+ if (!options.baseUrl) throw new Error("baseUrl \uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
132
+ if (!options.secretKey?.startsWith("sk_")) {
133
+ throw new Error("secretKey \uB294 sk_ \uB85C \uC2DC\uC791\uD558\uB294 \uBE44\uBC00 \uD0A4\uC5EC\uC57C \uD569\uB2C8\uB2E4 (pk_ \uB294 \uACF5\uAC1C \uD0A4\uC785\uB2C8\uB2E4)");
134
+ }
135
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
136
+ this.secretKey = options.secretKey;
137
+ this.doFetch = options.fetch ?? globalThis.fetch;
138
+ this.timeoutMs = options.timeoutMs ?? 1e4;
139
+ }
140
+ // ── 토큰 ────────────────────────────────────────────────────────────────
141
+ /**
142
+ * 위임장을 짧은 수명의 토큰으로 바꾼다.
143
+ *
144
+ * 이 호출은 **에이전트 자신이** 한다. 에이전트의 머신 자격증명이 필요하고,
145
+ * Identity 는 그것이 이 위임장의 에이전트가 맞는지 확인한다 — 없으면 유효한
146
+ * 머신 토큰 하나로 시스템의 모든 위임장을 쓸 수 있게 된다.
147
+ */
148
+ async issueAgentToken(options) {
149
+ const headers = {
150
+ "Content-Type": "application/json",
151
+ Authorization: `Bearer ${options.machineToken}`
152
+ };
153
+ if (options.dpopProof) headers.DPoP = options.dpopProof;
154
+ return this.send("POST", "/api/agent/token", headers, {
155
+ grantId: options.grantId,
156
+ audience: options.audience,
157
+ scope: options.scope
158
+ });
159
+ }
160
+ // ── 내부 ────────────────────────────────────────────────────────────────
161
+ request(method, path, body) {
162
+ return this.send(
163
+ method,
164
+ path,
165
+ {
166
+ "Content-Type": "application/json",
167
+ Authorization: `Bearer ${this.secretKey}`
168
+ },
169
+ body
170
+ );
171
+ }
172
+ async send(method, path, headers, body) {
173
+ const controller = new AbortController();
174
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
175
+ let response;
176
+ try {
177
+ response = await this.doFetch(`${this.baseUrl}${path}`, {
178
+ method,
179
+ headers,
180
+ body: body === void 0 ? void 0 : JSON.stringify(stripUndefined(body)),
181
+ signal: controller.signal
182
+ });
183
+ } finally {
184
+ clearTimeout(timer);
185
+ }
186
+ if (response.status === 204) return void 0;
187
+ const text = await response.text();
188
+ let parsed;
189
+ try {
190
+ parsed = text ? JSON.parse(text) : {};
191
+ } catch {
192
+ parsed = {};
193
+ }
194
+ if (!response.ok) {
195
+ const payload = parsed;
196
+ throw new DelegationError(
197
+ response.status,
198
+ payload.error ?? "unknown_error",
199
+ payload.details ?? payload.error_description ?? `${method} ${path} \u2192 ${response.status}`,
200
+ payload.details
201
+ );
202
+ }
203
+ return parsed;
204
+ }
205
+ };
206
+ function stripUndefined(value) {
207
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
208
+ const out = {};
209
+ for (const [k, v] of Object.entries(value)) {
210
+ if (v !== void 0) out[k] = v;
211
+ }
212
+ return out;
213
+ }
214
+ function createDelegationClient(options) {
215
+ return new DelegationClient(options);
216
+ }
217
+
218
+ // src/verifier.ts
219
+ import { createRemoteJWKSet, jwtVerify } from "jose";
220
+ var TokenVerifier = class {
221
+ constructor(options) {
222
+ if (!options.issuer) throw new Error("issuer \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4");
223
+ if (!options.audience) {
224
+ throw new Error(
225
+ "audience \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4 \u2014 \uB0B4 \uC11C\uBE44\uC2A4 \uC8FC\uC18C\uC785\uB2C8\uB2E4. \uBE7C\uBA74 \uB2E4\uB978 \uC11C\uBE44\uC2A4\uC6A9 \uD1A0\uD070\uB3C4 \uC5EC\uAE30\uC11C \uD1B5\uACFC\uD569\uB2C8\uB2E4."
226
+ );
227
+ }
228
+ this.issuer = options.issuer.replace(/\/+$/, "");
229
+ this.audience = options.audience.replace(/\/+$/, "");
230
+ this.introspectEnabled = options.introspect ?? false;
231
+ this.doFetch = options.fetch ?? globalThis.fetch;
232
+ this.jwks = createRemoteJWKSet(new URL(options.jwksUri ?? `${this.issuer}/.well-known/jwks.json`));
233
+ }
234
+ /**
235
+ * `Authorization` 헤더나 토큰 문자열을 받아 검사한다.
236
+ *
237
+ * 실패는 전부 {@link TokenRejected} 다. **거절 이유를 응답에 그대로 실어
238
+ * 보내지 않는다** — 어느 검사에서 걸렸는지 알려 주는 것은 공격자에게 다음
239
+ * 시도의 힌트를 주는 일이다. 이유는 로그에만 남긴다.
240
+ */
241
+ async verify(authorizationOrToken) {
242
+ const token = stripScheme(authorizationOrToken);
243
+ if (!token) throw new TokenRejected("no token");
244
+ let payload;
245
+ try {
246
+ const verified = await jwtVerify(token, this.jwks, {
247
+ issuer: this.issuer,
248
+ audience: this.audience,
249
+ algorithms: ["ES256"]
250
+ });
251
+ payload = verified.payload;
252
+ } catch (err) {
253
+ throw new TokenRejected(err.message);
254
+ }
255
+ const principalId = typeof payload.sub === "string" ? payload.sub : "";
256
+ const act = payload["act"] ?? null;
257
+ const agentId = act && typeof act.sub === "string" ? act.sub : "";
258
+ const grantId = typeof payload["gnt"] === "string" ? payload["gnt"] : "";
259
+ if (!principalId || !agentId || !grantId) {
260
+ throw new TokenRejected("sub / act.sub / gnt \uC911 \uBE60\uC9C4 \uAC83\uC774 \uC788\uC5B4 \uC704\uC784 \uD1A0\uD070\uC774 \uC544\uB2C8\uB2E4");
261
+ }
262
+ if (this.introspectEnabled && !await this.stillLive(token)) {
263
+ throw new TokenRejected("\uC704\uC784\uC774 \uCDE8\uC18C\uB410\uB2E4", true);
264
+ }
265
+ const scopes = typeof payload["scope"] === "string" ? payload["scope"].split(/\s+/).filter(Boolean) : [];
266
+ return {
267
+ principalId,
268
+ principalKind: typeof payload["principal_kind"] === "string" ? payload["principal_kind"] : void 0,
269
+ agentId,
270
+ agentType: act && typeof act.type === "string" ? act.type : void 0,
271
+ email: typeof payload["email"] === "string" ? payload["email"] : void 0,
272
+ organizationId: typeof payload["org"] === "string" ? payload["org"] : void 0,
273
+ grantId,
274
+ grantVersion: typeof payload["gv"] === "number" ? payload["gv"] : void 0,
275
+ scopes,
276
+ audience: normalizeAudience(payload.aud),
277
+ expiresAt: new Date((payload.exp ?? 0) * 1e3),
278
+ has: (scope) => scopes.includes(scope),
279
+ claims: payload
280
+ };
281
+ }
282
+ /**
283
+ * Identity 에 위임장이 아직 살아 있는지 묻는다.
284
+ *
285
+ * **네트워크가 안 되면 통과시킨다.** 서명과 만료는 이미 확인했고, Identity 가
286
+ * 잠깐 안 뜬다고 정상 요청이 전부 막히는 편이 더 나쁘다. 확실히 거절된
287
+ * 경우(401·403)에만 막는다.
288
+ */
289
+ async stillLive(token) {
290
+ try {
291
+ const response = await this.doFetch(`${this.issuer}/api/agent/verify`, {
292
+ headers: { Authorization: `Bearer ${token}` },
293
+ cache: "no-store"
294
+ });
295
+ return !(response.status === 401 || response.status === 403);
296
+ } catch {
297
+ return true;
298
+ }
299
+ }
300
+ };
301
+ function createTokenVerifier(options) {
302
+ return new TokenVerifier(options);
303
+ }
304
+ function stripScheme(value) {
305
+ const trimmed = value.trim();
306
+ const lower = trimmed.toLowerCase();
307
+ for (const scheme of ["bearer ", "dpop "]) {
308
+ if (lower.startsWith(scheme)) return trimmed.slice(scheme.length).trim();
309
+ }
310
+ return trimmed;
311
+ }
312
+ function normalizeAudience(aud) {
313
+ if (typeof aud === "string") return [aud];
314
+ if (Array.isArray(aud)) return aud.filter((a) => typeof a === "string");
315
+ return [];
316
+ }
317
+ export {
318
+ DelegationClient,
319
+ DelegationError,
320
+ TokenRejected,
321
+ TokenVerifier,
322
+ createDelegationClient,
323
+ createTokenVerifier
324
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@wegooli/identity-delegation",
3
+ "version": "0.2.0",
4
+ "description": "위임 만들기·거두기·토큰 받기·토큰 검사 — 서버에서 쓰는 라이브러리",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "dependencies": {
19
+ "jose": "^5.9.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.0.0",
23
+ "tsup": "^8.0.0",
24
+ "typescript": "^5.5.0",
25
+ "vitest": "^2.0.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/wegooli/javascript.git",
37
+ "directory": "packages/identity-delegation"
38
+ },
39
+ "homepage": "https://github.com/wegooli/javascript/tree/main/packages/identity-delegation#readme",
40
+ "bugs": "https://github.com/wegooli/javascript/issues",
41
+ "keywords": [
42
+ "identity",
43
+ "delegation",
44
+ "agent",
45
+ "oauth",
46
+ "mcp",
47
+ "server"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsup src/index.ts --format esm,cjs --dts",
51
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
52
+ "typecheck": "tsc --noEmit",
53
+ "lint": "eslint src --ext .ts",
54
+ "test": "vitest run"
55
+ }
56
+ }