@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/LICENSE +21 -0
- package/README.md +197 -0
- package/dist/index.d.mts +323 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.js +356 -0
- package/dist/index.mjs +324 -0
- package/package.json +56 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 위임에 관한 값들. Identity 가 돌려주는 JSON 을 그대로 옮긴 모양이다.
|
|
3
|
+
*/
|
|
4
|
+
/** 위임장 — "에이전트 A 가 주체 P 를 대신해 무엇까지, 언제까지" */
|
|
5
|
+
interface Grant {
|
|
6
|
+
id: string;
|
|
7
|
+
organizationId: string;
|
|
8
|
+
agentId: string;
|
|
9
|
+
/** `user` = 우리 로그인 사용자, `external_user` = 고객사가 서명해서 알려준 사람, `organization` = 조직 */
|
|
10
|
+
principalKind: 'user' | 'organization' | 'external_user';
|
|
11
|
+
principalId: string;
|
|
12
|
+
scope: string;
|
|
13
|
+
allowedAudiences: string[];
|
|
14
|
+
grantedVia: string;
|
|
15
|
+
version: number;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
revokedAt?: string | null;
|
|
18
|
+
revokedReason?: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
}
|
|
22
|
+
/** 고객사 인증서버가 서명해서 알려준 사람. 우리 로그인 사용자가 아니다. */
|
|
23
|
+
interface ExternalPrincipal {
|
|
24
|
+
id: string;
|
|
25
|
+
organizationId: string;
|
|
26
|
+
issuerDid: string;
|
|
27
|
+
subject: string;
|
|
28
|
+
email?: string;
|
|
29
|
+
displayName?: string;
|
|
30
|
+
lastAssertedAt: string;
|
|
31
|
+
}
|
|
32
|
+
/** 위임장을 만들 때 진술로 밝혀진 사람 (진술을 보냈을 때만 온다) */
|
|
33
|
+
interface AssertedPrincipal {
|
|
34
|
+
id: string;
|
|
35
|
+
kind: string;
|
|
36
|
+
issuerDid: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
email?: string;
|
|
39
|
+
displayName?: string;
|
|
40
|
+
}
|
|
41
|
+
interface EnsureGrantResult {
|
|
42
|
+
grant: Grant;
|
|
43
|
+
/** 처음 만들어졌으면 true, 이미 있던 것을 돌려받았으면 false */
|
|
44
|
+
created: boolean;
|
|
45
|
+
principal?: AssertedPrincipal;
|
|
46
|
+
}
|
|
47
|
+
/** 리소스 서버가 선언하는 권한 하나 */
|
|
48
|
+
interface ResourceScope {
|
|
49
|
+
name: string;
|
|
50
|
+
/** 동의 화면에 그려질 문장. 1인칭 현재형으로 쓴다 — "내 문서를 읽습니다" */
|
|
51
|
+
displayName: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
/** 되돌리기 어려운 권한. 화면이 눈에 띄게 그린다 */
|
|
54
|
+
isSensitive?: boolean;
|
|
55
|
+
}
|
|
56
|
+
interface ResourceServer {
|
|
57
|
+
id: string;
|
|
58
|
+
organizationId: string;
|
|
59
|
+
identifier: string;
|
|
60
|
+
displayName: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
metadataUrl?: string;
|
|
63
|
+
isEnabled: boolean;
|
|
64
|
+
scopes: ResourceScope[];
|
|
65
|
+
}
|
|
66
|
+
/** 위임 토큰이 밝혀 준 것 */
|
|
67
|
+
interface AgentCaller {
|
|
68
|
+
/** 이 요청의 결과에 책임지는 사람/조직 — 토큰의 `sub` */
|
|
69
|
+
principalId: string;
|
|
70
|
+
/** `user` / `organization` / `external_user`. 없을 수도 있다 */
|
|
71
|
+
principalKind?: string;
|
|
72
|
+
/** 실제로 요청을 보낸 에이전트 — 토큰의 `act.sub` */
|
|
73
|
+
agentId: string;
|
|
74
|
+
agentType?: string;
|
|
75
|
+
/** 책임 주체의 이메일. 고객사 진술에서 왔든 우리 표에서 왔든 모양이 같다 */
|
|
76
|
+
email?: string;
|
|
77
|
+
organizationId?: string;
|
|
78
|
+
/** 어느 위임장에서 나온 권한인가. 사고 뒤 "무엇이 허락돼 있었나" 의 근거 */
|
|
79
|
+
grantId: string;
|
|
80
|
+
grantVersion?: number;
|
|
81
|
+
scopes: string[];
|
|
82
|
+
/** 이 토큰이 향하도록 허락된 주소 */
|
|
83
|
+
audience: string[];
|
|
84
|
+
expiresAt: Date;
|
|
85
|
+
/** 이 권한이 위임장에 있는가 */
|
|
86
|
+
has(scope: string): boolean;
|
|
87
|
+
/** 원본 클레임. 위의 것으로 부족할 때만 본다 */
|
|
88
|
+
claims: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface DelegationClientOptions {
|
|
92
|
+
/** Identity 주소. 예: `https://api.freezz.kr` */
|
|
93
|
+
baseUrl: string;
|
|
94
|
+
/** `sk_live_…` 비밀 키. **브라우저에 절대 넣지 않는다** */
|
|
95
|
+
secretKey: string;
|
|
96
|
+
/** 테스트나 프록시용. 기본은 전역 fetch */
|
|
97
|
+
fetch?: typeof globalThis.fetch;
|
|
98
|
+
/** 한 요청의 제한 시간(ms). 기본 10초 */
|
|
99
|
+
timeoutMs?: number;
|
|
100
|
+
}
|
|
101
|
+
interface EnsureGrantOptions {
|
|
102
|
+
agentId: string;
|
|
103
|
+
/**
|
|
104
|
+
* 우리 로그인을 쓰는 사람의 id. 자체 인증 고객이라면 이 대신
|
|
105
|
+
* `principalAssertion` 을 보낸다. **둘 중 정확히 하나**만 보낸다.
|
|
106
|
+
*/
|
|
107
|
+
principalId?: string;
|
|
108
|
+
/**
|
|
109
|
+
* 고객사 인증서버가 서명한 진술. "지금 이 버튼을 누른 사람은 우리 직원
|
|
110
|
+
* 아무개다" 를 서명한 문서다. 만드는 법은 통합 문서를 참고한다.
|
|
111
|
+
*/
|
|
112
|
+
principalAssertion?: string;
|
|
113
|
+
/** 공백으로 구분한 권한 이름. 리소스 서버가 선언한 것만 쓸 수 있다 */
|
|
114
|
+
scope: string;
|
|
115
|
+
/** 이 위임으로 받은 토큰이 향할 수 있는 서비스 주소 */
|
|
116
|
+
audience: string | string[];
|
|
117
|
+
/** 며칠 뒤 만료할지. 외부 주체는 최대 1일 */
|
|
118
|
+
expiresInDays?: number;
|
|
119
|
+
/** 절대 시각으로 주고 싶을 때 (RFC 3339) */
|
|
120
|
+
expiresAt?: string;
|
|
121
|
+
/** 화면에서 사람이 본 문구·시각 등. 그대로 보관된다 */
|
|
122
|
+
consentEvidence?: Record<string, unknown>;
|
|
123
|
+
/**
|
|
124
|
+
* 껐던 사람이 **화면에서 다시 동의를 눌렀을 때만** true 로 보낸다.
|
|
125
|
+
* 재시도 로직이 자동으로 붙이면 안 된다 — 그 순간 끄기가 의미를 잃는다.
|
|
126
|
+
*/
|
|
127
|
+
reconsent?: boolean;
|
|
128
|
+
/** RFC 9396 — 금액 한도 같은, 권한 이름으로 표현 못 하는 제한 */
|
|
129
|
+
authorizationDetails?: unknown;
|
|
130
|
+
}
|
|
131
|
+
interface IssueTokenOptions {
|
|
132
|
+
grantId: string;
|
|
133
|
+
audience: string;
|
|
134
|
+
/** 위임장의 권한 중 일부만 원할 때. 비우면 위임장 전체 */
|
|
135
|
+
scope?: string;
|
|
136
|
+
/**
|
|
137
|
+
* 에이전트 자신의 머신 자격증명(ZITADEL 이 발급한 JWT). Identity 는 이 값으로
|
|
138
|
+
* "이 위임장이 정말 이 에이전트 것인가" 를 확인한다.
|
|
139
|
+
*/
|
|
140
|
+
machineToken: string;
|
|
141
|
+
/** 키에 묶인 에이전트라면 DPoP 증명 */
|
|
142
|
+
dpopProof?: string;
|
|
143
|
+
}
|
|
144
|
+
interface IssuedToken {
|
|
145
|
+
access_token: string;
|
|
146
|
+
token_type: string;
|
|
147
|
+
expires_in: number;
|
|
148
|
+
scope: string;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Identity 의 서버 간 API 를 감싼다.
|
|
152
|
+
*
|
|
153
|
+
* ── 왜 "끄기" 가 "켜기" 와 같은 자리에 있는가 ──────────────────────────────
|
|
154
|
+
*
|
|
155
|
+
* 이 라이브러리가 존재하는 이유의 절반은 `revoke` 다. 토큰 검사만 제공하고
|
|
156
|
+
* 위임 만들기를 각자 짜게 두면, 고객마다 다르게 짜게 되고 그중 몇은 반드시
|
|
157
|
+
* **끄는 자리가 빠진 채로** 나온다. 스페이스노트에서 실제로 그렇게 됐다 —
|
|
158
|
+
* "언제든 끌 수 있습니다" 라고 화면에 적어 두고, 끄는 버튼이 없었다.
|
|
159
|
+
*
|
|
160
|
+
* 그래서 `ensure` 와 `revoke` 는 같은 객체에 있고, 인자 수도 비슷하다.
|
|
161
|
+
* 켜는 코드를 쓴 사람이 끄는 코드를 못 찾는 일이 없게 하려는 것이다.
|
|
162
|
+
*/
|
|
163
|
+
declare class DelegationClient {
|
|
164
|
+
private readonly baseUrl;
|
|
165
|
+
private readonly secretKey;
|
|
166
|
+
private readonly doFetch;
|
|
167
|
+
private readonly timeoutMs;
|
|
168
|
+
constructor(options: DelegationClientOptions);
|
|
169
|
+
readonly grants: {
|
|
170
|
+
/**
|
|
171
|
+
* 사람이 스위치를 켰을 때 부른다. **같은 (에이전트, 사람) 조합으로 여러 번
|
|
172
|
+
* 불러도 위임장이 하나만 생긴다** — 사람은 스위치를 두 번 누르고, 위임장이
|
|
173
|
+
* 둘이면 끌 때 하나만 꺼서는 안 멈춘다.
|
|
174
|
+
*
|
|
175
|
+
* 외부 주체(자체 인증 고객의 직원)라면 **이 호출이 갱신도 겸한다.** 새
|
|
176
|
+
* 진술을 붙여 다시 부르면 만료일이 밀린다. 진술을 못 만들게 되면(퇴사)
|
|
177
|
+
* 아무도 아무것도 안 해도 위임장이 스스로 닫힌다.
|
|
178
|
+
*/
|
|
179
|
+
ensure: (options: EnsureGrantOptions) => Promise<EnsureGrantResult>;
|
|
180
|
+
/** 이 에이전트가 들고 있는 위임장들. 끄기 화면이 그리는 목록이다. */
|
|
181
|
+
list: (options: {
|
|
182
|
+
agentId: string;
|
|
183
|
+
}) => Promise<Grant[]>;
|
|
184
|
+
/**
|
|
185
|
+
* 끈다. **다음 검증 시점에 바로** 먹는다 — 이미 나가 있는 토큰도 무효가 된다.
|
|
186
|
+
*
|
|
187
|
+
* 이유를 적어 두면 나중에 "왜 멈췄나" 에 답할 수 있다. 사람이 껐는지,
|
|
188
|
+
* 관리자가 껐는지, 사고 대응이었는지가 전부 다른 이야기다.
|
|
189
|
+
*/
|
|
190
|
+
revoke: (grantId: string, reason?: string) => Promise<void>;
|
|
191
|
+
};
|
|
192
|
+
readonly externalPrincipals: {
|
|
193
|
+
/** 진술로 알게 된 사람들. 우리 로그인 사용자가 아니다. */
|
|
194
|
+
list: () => Promise<ExternalPrincipal[]>;
|
|
195
|
+
};
|
|
196
|
+
readonly resourceServers: {
|
|
197
|
+
/**
|
|
198
|
+
* 내 서비스가 받는 권한 이름과 그 뜻을 선언한다. 동의 화면이 이 문장을
|
|
199
|
+
* 그대로 그린다.
|
|
200
|
+
*
|
|
201
|
+
* 배포할 때마다 불러도 된다 — 주소가 같으면 행이 하나로 유지되고, 선언은
|
|
202
|
+
* 통째로 바뀐다. 권한 하나를 목록에서 빼면 그 권한은 더 이상 위임될 수 없다.
|
|
203
|
+
*/
|
|
204
|
+
declare: (options: {
|
|
205
|
+
identifier: string;
|
|
206
|
+
displayName: string;
|
|
207
|
+
description?: string;
|
|
208
|
+
metadataUrl?: string;
|
|
209
|
+
scopes: ResourceScope[];
|
|
210
|
+
}) => Promise<ResourceServer>;
|
|
211
|
+
list: () => Promise<ResourceServer[]>;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* 위임장을 짧은 수명의 토큰으로 바꾼다.
|
|
215
|
+
*
|
|
216
|
+
* 이 호출은 **에이전트 자신이** 한다. 에이전트의 머신 자격증명이 필요하고,
|
|
217
|
+
* Identity 는 그것이 이 위임장의 에이전트가 맞는지 확인한다 — 없으면 유효한
|
|
218
|
+
* 머신 토큰 하나로 시스템의 모든 위임장을 쓸 수 있게 된다.
|
|
219
|
+
*/
|
|
220
|
+
issueAgentToken(options: IssueTokenOptions): Promise<IssuedToken>;
|
|
221
|
+
private request;
|
|
222
|
+
private send;
|
|
223
|
+
}
|
|
224
|
+
/** 위임을 만들고 거두는 서버용 클라이언트를 만든다. */
|
|
225
|
+
declare function createDelegationClient(options: DelegationClientOptions): DelegationClient;
|
|
226
|
+
|
|
227
|
+
interface TokenVerifierOptions {
|
|
228
|
+
/** Identity 주소. 토큰의 `iss` 와 정확히 같아야 한다 */
|
|
229
|
+
issuer: string;
|
|
230
|
+
/**
|
|
231
|
+
* **내 서비스의 주소.** 토큰의 `aud` 가 이것이어야 통과한다.
|
|
232
|
+
*
|
|
233
|
+
* 필수다. 빼면 다른 서비스용으로 발급된 토큰이 여기서도 통과하고, 그 순간
|
|
234
|
+
* 위임장의 "이 토큰은 어디로 갈 수 있다" 는 제한이 아무것도 막지 않게 된다.
|
|
235
|
+
*/
|
|
236
|
+
audience: string;
|
|
237
|
+
/** 기본은 `<issuer>/.well-known/jwks.json` */
|
|
238
|
+
jwksUri?: string;
|
|
239
|
+
/**
|
|
240
|
+
* 매 요청마다 Identity 에 "이 위임이 아직 살아 있나" 를 묻는다.
|
|
241
|
+
*
|
|
242
|
+
* 안 물으면 취소를 토큰 수명(약 5분)만큼 늦게 본다. 계약서 발송처럼
|
|
243
|
+
* 되돌리기 어려운 동작 앞에서는 켠다. 화면 하나 그리는 데는 안 켜도 된다.
|
|
244
|
+
*/
|
|
245
|
+
introspect?: boolean;
|
|
246
|
+
fetch?: typeof globalThis.fetch;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Identity 가 발급한 위임 토큰을 검사한다.
|
|
250
|
+
*
|
|
251
|
+
* 기존 로그인 토큰과 다른 점은 하나다. 이 토큰은 "누가 불렀는가" 뿐 아니라
|
|
252
|
+
* **"누구를 대신해서"** 를 담고 있다 (RFC 8693).
|
|
253
|
+
*
|
|
254
|
+
* sub = 책임 주체 — 이 요청의 결과에 책임지는 사람
|
|
255
|
+
* act.sub = 행위자 — 실제로 요청을 보낸 에이전트
|
|
256
|
+
*
|
|
257
|
+
* 순서가 거꾸로 보이지만 그게 요점이다. **sub 으로 권한을 판단하던 기존 코드가
|
|
258
|
+
* 그대로 동작한다.** act 는 "사람이 아니라 기계가 했다" 를 기록에 남기고 싶을
|
|
259
|
+
* 때만 읽으면 된다.
|
|
260
|
+
*
|
|
261
|
+
* 그리고 그 사람이 우리 로그인 사용자든 고객사 직원이든 **토큰 모양이 같다.**
|
|
262
|
+
* 여기서 둘을 구별하는 분기를 쓸 일은 없다.
|
|
263
|
+
*/
|
|
264
|
+
declare class TokenVerifier {
|
|
265
|
+
private readonly issuer;
|
|
266
|
+
private readonly audience;
|
|
267
|
+
private readonly jwks;
|
|
268
|
+
private readonly introspectEnabled;
|
|
269
|
+
private readonly doFetch;
|
|
270
|
+
constructor(options: TokenVerifierOptions);
|
|
271
|
+
/**
|
|
272
|
+
* `Authorization` 헤더나 토큰 문자열을 받아 검사한다.
|
|
273
|
+
*
|
|
274
|
+
* 실패는 전부 {@link TokenRejected} 다. **거절 이유를 응답에 그대로 실어
|
|
275
|
+
* 보내지 않는다** — 어느 검사에서 걸렸는지 알려 주는 것은 공격자에게 다음
|
|
276
|
+
* 시도의 힌트를 주는 일이다. 이유는 로그에만 남긴다.
|
|
277
|
+
*/
|
|
278
|
+
verify(authorizationOrToken: string): Promise<AgentCaller>;
|
|
279
|
+
/**
|
|
280
|
+
* Identity 에 위임장이 아직 살아 있는지 묻는다.
|
|
281
|
+
*
|
|
282
|
+
* **네트워크가 안 되면 통과시킨다.** 서명과 만료는 이미 확인했고, Identity 가
|
|
283
|
+
* 잠깐 안 뜬다고 정상 요청이 전부 막히는 편이 더 나쁘다. 확실히 거절된
|
|
284
|
+
* 경우(401·403)에만 막는다.
|
|
285
|
+
*/
|
|
286
|
+
private stillLive;
|
|
287
|
+
}
|
|
288
|
+
/** 위임 토큰 검사기를 만든다. */
|
|
289
|
+
declare function createTokenVerifier(options: TokenVerifierOptions): TokenVerifier;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Identity 가 거절했을 때 던지는 오류.
|
|
293
|
+
*
|
|
294
|
+
* `code` 는 Identity 가 돌려준 기계용 값이고, `message` 는 사람이 읽을 값이다.
|
|
295
|
+
* 둘을 나눠 두는 이유는 하나다 — 어떤 거절은 재시도로 풀리지 않고 사람이 다시
|
|
296
|
+
* 눌러야 풀린다. 코드로 갈라야 그 둘을 구별할 수 있다.
|
|
297
|
+
*/
|
|
298
|
+
declare class DelegationError extends Error {
|
|
299
|
+
readonly status: number;
|
|
300
|
+
readonly code: string;
|
|
301
|
+
readonly details?: string;
|
|
302
|
+
constructor(status: number, code: string, message: string, details?: string);
|
|
303
|
+
/**
|
|
304
|
+
* 그 사람이 껐던 위임을 다시 만들려 했다는 뜻이다.
|
|
305
|
+
*
|
|
306
|
+
* 이건 오류가 아니라 **설계**다. 사람이 끈 위임을 백엔드가 조용히 다시 만들면
|
|
307
|
+
* 그 사람이 누른 스위치는 아무것도 안 한 게 된다. 다시 켜려면 화면에서 사람이
|
|
308
|
+
* 다시 동의를 누르고, 그 동의를 근거로 `reconsent: true` 를 보내야 한다.
|
|
309
|
+
*/
|
|
310
|
+
get isRevokedByPrincipal(): boolean;
|
|
311
|
+
/** 진술이 거절됐다 — 서명·수신자·수명·발급자 중 하나가 조건을 못 맞췄다 */
|
|
312
|
+
get isAssertionRejected(): boolean;
|
|
313
|
+
}
|
|
314
|
+
/** 토큰 검사가 실패했을 때. 이유는 로그로만 남기고 호출자에게는 한 가지로 준다. */
|
|
315
|
+
declare class TokenRejected extends Error {
|
|
316
|
+
/** 왜 거절됐는지. 응답에 그대로 실어 보내지 말 것 — 공격자에게 힌트가 된다. */
|
|
317
|
+
readonly reason: string;
|
|
318
|
+
/** 위임이 취소돼서 거절됐는가. 이건 401 이 아니라 403 이다. */
|
|
319
|
+
readonly revoked: boolean;
|
|
320
|
+
constructor(reason: string, revoked?: boolean);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export { type AgentCaller, type AssertedPrincipal, DelegationClient, type DelegationClientOptions, DelegationError, type EnsureGrantOptions, type EnsureGrantResult, type ExternalPrincipal, type Grant, type IssueTokenOptions, type IssuedToken, type ResourceScope, type ResourceServer, TokenRejected, TokenVerifier, type TokenVerifierOptions, createDelegationClient, createTokenVerifier };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
DelegationClient: () => DelegationClient,
|
|
24
|
+
DelegationError: () => DelegationError,
|
|
25
|
+
TokenRejected: () => TokenRejected,
|
|
26
|
+
TokenVerifier: () => TokenVerifier,
|
|
27
|
+
createDelegationClient: () => createDelegationClient,
|
|
28
|
+
createTokenVerifier: () => createTokenVerifier
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/errors.ts
|
|
33
|
+
var DelegationError = class extends Error {
|
|
34
|
+
constructor(status, code, message, details) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "DelegationError";
|
|
37
|
+
this.status = status;
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.details = details;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 그 사람이 껐던 위임을 다시 만들려 했다는 뜻이다.
|
|
43
|
+
*
|
|
44
|
+
* 이건 오류가 아니라 **설계**다. 사람이 끈 위임을 백엔드가 조용히 다시 만들면
|
|
45
|
+
* 그 사람이 누른 스위치는 아무것도 안 한 게 된다. 다시 켜려면 화면에서 사람이
|
|
46
|
+
* 다시 동의를 누르고, 그 동의를 근거로 `reconsent: true` 를 보내야 한다.
|
|
47
|
+
*/
|
|
48
|
+
get isRevokedByPrincipal() {
|
|
49
|
+
return this.code === "grant_revoked";
|
|
50
|
+
}
|
|
51
|
+
/** 진술이 거절됐다 — 서명·수신자·수명·발급자 중 하나가 조건을 못 맞췄다 */
|
|
52
|
+
get isAssertionRejected() {
|
|
53
|
+
return this.code.startsWith("assertion_") || this.code === "issuer_not_trusted";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var TokenRejected = class extends Error {
|
|
57
|
+
constructor(reason, revoked = false) {
|
|
58
|
+
super(`token rejected: ${reason}`);
|
|
59
|
+
this.name = "TokenRejected";
|
|
60
|
+
this.reason = reason;
|
|
61
|
+
this.revoked = revoked;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// src/client.ts
|
|
66
|
+
var DelegationClient = class {
|
|
67
|
+
constructor(options) {
|
|
68
|
+
// ── 위임 ────────────────────────────────────────────────────────────────
|
|
69
|
+
this.grants = {
|
|
70
|
+
/**
|
|
71
|
+
* 사람이 스위치를 켰을 때 부른다. **같은 (에이전트, 사람) 조합으로 여러 번
|
|
72
|
+
* 불러도 위임장이 하나만 생긴다** — 사람은 스위치를 두 번 누르고, 위임장이
|
|
73
|
+
* 둘이면 끌 때 하나만 꺼서는 안 멈춘다.
|
|
74
|
+
*
|
|
75
|
+
* 외부 주체(자체 인증 고객의 직원)라면 **이 호출이 갱신도 겸한다.** 새
|
|
76
|
+
* 진술을 붙여 다시 부르면 만료일이 밀린다. 진술을 못 만들게 되면(퇴사)
|
|
77
|
+
* 아무도 아무것도 안 해도 위임장이 스스로 닫힌다.
|
|
78
|
+
*/
|
|
79
|
+
// `async` 인 것이 중요하다. 인자 검사를 동기로 던지면
|
|
80
|
+
// `ensure(...).catch(handle)` 로 쓴 코드가 그 오류만 못 받는다 — 같은 함수가
|
|
81
|
+
// 어떤 실패는 던지고 어떤 실패는 거절하는 API 는 반드시 한쪽을 놓치게 한다.
|
|
82
|
+
ensure: async (options) => {
|
|
83
|
+
const hasId = Boolean(options.principalId);
|
|
84
|
+
const hasAssertion = Boolean(options.principalAssertion);
|
|
85
|
+
if (hasId === hasAssertion) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"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"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return this.request("POST", "/v1/agent-grants", {
|
|
91
|
+
agentId: options.agentId,
|
|
92
|
+
principalId: options.principalId,
|
|
93
|
+
principalAssertion: options.principalAssertion,
|
|
94
|
+
scope: options.scope,
|
|
95
|
+
allowedAudiences: Array.isArray(options.audience) ? options.audience : [options.audience],
|
|
96
|
+
expiresInDays: options.expiresInDays,
|
|
97
|
+
expiresAt: options.expiresAt,
|
|
98
|
+
consentEvidence: options.consentEvidence,
|
|
99
|
+
reconsent: options.reconsent,
|
|
100
|
+
authorizationDetails: options.authorizationDetails
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
/** 이 에이전트가 들고 있는 위임장들. 끄기 화면이 그리는 목록이다. */
|
|
104
|
+
list: async (options) => {
|
|
105
|
+
const out = await this.request(
|
|
106
|
+
"GET",
|
|
107
|
+
`/v1/agent-grants?agentId=${encodeURIComponent(options.agentId)}`
|
|
108
|
+
);
|
|
109
|
+
return out.grants ?? [];
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* 끈다. **다음 검증 시점에 바로** 먹는다 — 이미 나가 있는 토큰도 무효가 된다.
|
|
113
|
+
*
|
|
114
|
+
* 이유를 적어 두면 나중에 "왜 멈췄나" 에 답할 수 있다. 사람이 껐는지,
|
|
115
|
+
* 관리자가 껐는지, 사고 대응이었는지가 전부 다른 이야기다.
|
|
116
|
+
*/
|
|
117
|
+
revoke: (grantId, reason) => this.request("POST", `/v1/agent-grants/${encodeURIComponent(grantId)}/revoke`, {
|
|
118
|
+
reason
|
|
119
|
+
})
|
|
120
|
+
};
|
|
121
|
+
// ── 외부 주체 ────────────────────────────────────────────────────────────
|
|
122
|
+
this.externalPrincipals = {
|
|
123
|
+
/** 진술로 알게 된 사람들. 우리 로그인 사용자가 아니다. */
|
|
124
|
+
list: async () => {
|
|
125
|
+
const out = await this.request(
|
|
126
|
+
"GET",
|
|
127
|
+
"/v1/external-principals"
|
|
128
|
+
);
|
|
129
|
+
return out.externalPrincipals ?? [];
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
// ── 내 서비스의 권한 어휘 ────────────────────────────────────────────────
|
|
133
|
+
this.resourceServers = {
|
|
134
|
+
/**
|
|
135
|
+
* 내 서비스가 받는 권한 이름과 그 뜻을 선언한다. 동의 화면이 이 문장을
|
|
136
|
+
* 그대로 그린다.
|
|
137
|
+
*
|
|
138
|
+
* 배포할 때마다 불러도 된다 — 주소가 같으면 행이 하나로 유지되고, 선언은
|
|
139
|
+
* 통째로 바뀐다. 권한 하나를 목록에서 빼면 그 권한은 더 이상 위임될 수 없다.
|
|
140
|
+
*/
|
|
141
|
+
declare: async (options) => {
|
|
142
|
+
const out = await this.request(
|
|
143
|
+
"PUT",
|
|
144
|
+
"/v1/resource-servers",
|
|
145
|
+
options
|
|
146
|
+
);
|
|
147
|
+
return out.resourceServer;
|
|
148
|
+
},
|
|
149
|
+
list: async () => {
|
|
150
|
+
const out = await this.request(
|
|
151
|
+
"GET",
|
|
152
|
+
"/v1/resource-servers"
|
|
153
|
+
);
|
|
154
|
+
return out.resourceServers ?? [];
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"@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."
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!options.baseUrl) throw new Error("baseUrl \uC774 \uD544\uC694\uD569\uB2C8\uB2E4");
|
|
163
|
+
if (!options.secretKey?.startsWith("sk_")) {
|
|
164
|
+
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)");
|
|
165
|
+
}
|
|
166
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
167
|
+
this.secretKey = options.secretKey;
|
|
168
|
+
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
169
|
+
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
170
|
+
}
|
|
171
|
+
// ── 토큰 ────────────────────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* 위임장을 짧은 수명의 토큰으로 바꾼다.
|
|
174
|
+
*
|
|
175
|
+
* 이 호출은 **에이전트 자신이** 한다. 에이전트의 머신 자격증명이 필요하고,
|
|
176
|
+
* Identity 는 그것이 이 위임장의 에이전트가 맞는지 확인한다 — 없으면 유효한
|
|
177
|
+
* 머신 토큰 하나로 시스템의 모든 위임장을 쓸 수 있게 된다.
|
|
178
|
+
*/
|
|
179
|
+
async issueAgentToken(options) {
|
|
180
|
+
const headers = {
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
Authorization: `Bearer ${options.machineToken}`
|
|
183
|
+
};
|
|
184
|
+
if (options.dpopProof) headers.DPoP = options.dpopProof;
|
|
185
|
+
return this.send("POST", "/api/agent/token", headers, {
|
|
186
|
+
grantId: options.grantId,
|
|
187
|
+
audience: options.audience,
|
|
188
|
+
scope: options.scope
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// ── 내부 ────────────────────────────────────────────────────────────────
|
|
192
|
+
request(method, path, body) {
|
|
193
|
+
return this.send(
|
|
194
|
+
method,
|
|
195
|
+
path,
|
|
196
|
+
{
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
Authorization: `Bearer ${this.secretKey}`
|
|
199
|
+
},
|
|
200
|
+
body
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
async send(method, path, headers, body) {
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
206
|
+
let response;
|
|
207
|
+
try {
|
|
208
|
+
response = await this.doFetch(`${this.baseUrl}${path}`, {
|
|
209
|
+
method,
|
|
210
|
+
headers,
|
|
211
|
+
body: body === void 0 ? void 0 : JSON.stringify(stripUndefined(body)),
|
|
212
|
+
signal: controller.signal
|
|
213
|
+
});
|
|
214
|
+
} finally {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
}
|
|
217
|
+
if (response.status === 204) return void 0;
|
|
218
|
+
const text = await response.text();
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = text ? JSON.parse(text) : {};
|
|
222
|
+
} catch {
|
|
223
|
+
parsed = {};
|
|
224
|
+
}
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
const payload = parsed;
|
|
227
|
+
throw new DelegationError(
|
|
228
|
+
response.status,
|
|
229
|
+
payload.error ?? "unknown_error",
|
|
230
|
+
payload.details ?? payload.error_description ?? `${method} ${path} \u2192 ${response.status}`,
|
|
231
|
+
payload.details
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
function stripUndefined(value) {
|
|
238
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
239
|
+
const out = {};
|
|
240
|
+
for (const [k, v] of Object.entries(value)) {
|
|
241
|
+
if (v !== void 0) out[k] = v;
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
function createDelegationClient(options) {
|
|
246
|
+
return new DelegationClient(options);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/verifier.ts
|
|
250
|
+
var import_jose = require("jose");
|
|
251
|
+
var TokenVerifier = class {
|
|
252
|
+
constructor(options) {
|
|
253
|
+
if (!options.issuer) throw new Error("issuer \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4");
|
|
254
|
+
if (!options.audience) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"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."
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
this.issuer = options.issuer.replace(/\/+$/, "");
|
|
260
|
+
this.audience = options.audience.replace(/\/+$/, "");
|
|
261
|
+
this.introspectEnabled = options.introspect ?? false;
|
|
262
|
+
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
263
|
+
this.jwks = (0, import_jose.createRemoteJWKSet)(new URL(options.jwksUri ?? `${this.issuer}/.well-known/jwks.json`));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* `Authorization` 헤더나 토큰 문자열을 받아 검사한다.
|
|
267
|
+
*
|
|
268
|
+
* 실패는 전부 {@link TokenRejected} 다. **거절 이유를 응답에 그대로 실어
|
|
269
|
+
* 보내지 않는다** — 어느 검사에서 걸렸는지 알려 주는 것은 공격자에게 다음
|
|
270
|
+
* 시도의 힌트를 주는 일이다. 이유는 로그에만 남긴다.
|
|
271
|
+
*/
|
|
272
|
+
async verify(authorizationOrToken) {
|
|
273
|
+
const token = stripScheme(authorizationOrToken);
|
|
274
|
+
if (!token) throw new TokenRejected("no token");
|
|
275
|
+
let payload;
|
|
276
|
+
try {
|
|
277
|
+
const verified = await (0, import_jose.jwtVerify)(token, this.jwks, {
|
|
278
|
+
issuer: this.issuer,
|
|
279
|
+
audience: this.audience,
|
|
280
|
+
algorithms: ["ES256"]
|
|
281
|
+
});
|
|
282
|
+
payload = verified.payload;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new TokenRejected(err.message);
|
|
285
|
+
}
|
|
286
|
+
const principalId = typeof payload.sub === "string" ? payload.sub : "";
|
|
287
|
+
const act = payload["act"] ?? null;
|
|
288
|
+
const agentId = act && typeof act.sub === "string" ? act.sub : "";
|
|
289
|
+
const grantId = typeof payload["gnt"] === "string" ? payload["gnt"] : "";
|
|
290
|
+
if (!principalId || !agentId || !grantId) {
|
|
291
|
+
throw new TokenRejected("sub / act.sub / gnt \uC911 \uBE60\uC9C4 \uAC83\uC774 \uC788\uC5B4 \uC704\uC784 \uD1A0\uD070\uC774 \uC544\uB2C8\uB2E4");
|
|
292
|
+
}
|
|
293
|
+
if (this.introspectEnabled && !await this.stillLive(token)) {
|
|
294
|
+
throw new TokenRejected("\uC704\uC784\uC774 \uCDE8\uC18C\uB410\uB2E4", true);
|
|
295
|
+
}
|
|
296
|
+
const scopes = typeof payload["scope"] === "string" ? payload["scope"].split(/\s+/).filter(Boolean) : [];
|
|
297
|
+
return {
|
|
298
|
+
principalId,
|
|
299
|
+
principalKind: typeof payload["principal_kind"] === "string" ? payload["principal_kind"] : void 0,
|
|
300
|
+
agentId,
|
|
301
|
+
agentType: act && typeof act.type === "string" ? act.type : void 0,
|
|
302
|
+
email: typeof payload["email"] === "string" ? payload["email"] : void 0,
|
|
303
|
+
organizationId: typeof payload["org"] === "string" ? payload["org"] : void 0,
|
|
304
|
+
grantId,
|
|
305
|
+
grantVersion: typeof payload["gv"] === "number" ? payload["gv"] : void 0,
|
|
306
|
+
scopes,
|
|
307
|
+
audience: normalizeAudience(payload.aud),
|
|
308
|
+
expiresAt: new Date((payload.exp ?? 0) * 1e3),
|
|
309
|
+
has: (scope) => scopes.includes(scope),
|
|
310
|
+
claims: payload
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Identity 에 위임장이 아직 살아 있는지 묻는다.
|
|
315
|
+
*
|
|
316
|
+
* **네트워크가 안 되면 통과시킨다.** 서명과 만료는 이미 확인했고, Identity 가
|
|
317
|
+
* 잠깐 안 뜬다고 정상 요청이 전부 막히는 편이 더 나쁘다. 확실히 거절된
|
|
318
|
+
* 경우(401·403)에만 막는다.
|
|
319
|
+
*/
|
|
320
|
+
async stillLive(token) {
|
|
321
|
+
try {
|
|
322
|
+
const response = await this.doFetch(`${this.issuer}/api/agent/verify`, {
|
|
323
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
324
|
+
cache: "no-store"
|
|
325
|
+
});
|
|
326
|
+
return !(response.status === 401 || response.status === 403);
|
|
327
|
+
} catch {
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
function createTokenVerifier(options) {
|
|
333
|
+
return new TokenVerifier(options);
|
|
334
|
+
}
|
|
335
|
+
function stripScheme(value) {
|
|
336
|
+
const trimmed = value.trim();
|
|
337
|
+
const lower = trimmed.toLowerCase();
|
|
338
|
+
for (const scheme of ["bearer ", "dpop "]) {
|
|
339
|
+
if (lower.startsWith(scheme)) return trimmed.slice(scheme.length).trim();
|
|
340
|
+
}
|
|
341
|
+
return trimmed;
|
|
342
|
+
}
|
|
343
|
+
function normalizeAudience(aud) {
|
|
344
|
+
if (typeof aud === "string") return [aud];
|
|
345
|
+
if (Array.isArray(aud)) return aud.filter((a) => typeof a === "string");
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
349
|
+
0 && (module.exports = {
|
|
350
|
+
DelegationClient,
|
|
351
|
+
DelegationError,
|
|
352
|
+
TokenRejected,
|
|
353
|
+
TokenVerifier,
|
|
354
|
+
createDelegationClient,
|
|
355
|
+
createTokenVerifier
|
|
356
|
+
});
|