@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wegooli
|
|
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,197 @@
|
|
|
1
|
+
# @wegooli/identity-delegation
|
|
2
|
+
|
|
3
|
+
위임을 **만들고, 거두고, 검사하는** 서버용 라이브러리.
|
|
4
|
+
|
|
5
|
+
AI 에게 사람 대신 일을 시키려면 세 가지가 필요합니다. 누가 무엇을 허락했는지
|
|
6
|
+
기록하고, 그 사람이 마음을 바꾸면 즉시 끄고, 받은 쪽이 "이게 정말 허락된
|
|
7
|
+
일인가" 를 확인하는 것. **이 세 가지는 회사마다 글자 하나 다르지 않습니다.**
|
|
8
|
+
다른 것은 그다음 — 자기 데이터를 자기 양식에 어떻게 넣는가 — 이고, 그건 영원히
|
|
9
|
+
고객 몫입니다.
|
|
10
|
+
|
|
11
|
+
> ⚠️ **서버에서만 씁니다.** `sk_` 비밀 키로 인증하므로 브라우저에 들어가면
|
|
12
|
+
> 누구나 이 조직의 모든 위임을 만들고 지울 수 있습니다. 브라우저에서
|
|
13
|
+
> 불러들이면 라이브러리가 즉시 오류를 냅니다.
|
|
14
|
+
|
|
15
|
+
## 설치
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @wegooli/identity-delegation
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Node 20 이상.
|
|
22
|
+
|
|
23
|
+
## 켜기 — 사람이 스위치를 눌렀을 때
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createDelegationClient } from '@wegooli/identity-delegation';
|
|
27
|
+
|
|
28
|
+
const identity = createDelegationClient({
|
|
29
|
+
baseUrl: 'https://api.freezz.kr',
|
|
30
|
+
secretKey: process.env.WEGOOLI_SECRET_KEY!,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const { grant } = await identity.grants.ensure({
|
|
34
|
+
agentId,
|
|
35
|
+
principalId: user.id,
|
|
36
|
+
scope: 'sign:create',
|
|
37
|
+
audience: 'https://sign.wegooli.com',
|
|
38
|
+
expiresInDays: 90,
|
|
39
|
+
consentEvidence: { wording: '계약서 작성 자동화', at: new Date().toISOString() },
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**같은 (에이전트, 사람) 조합으로 여러 번 불러도 위임장은 하나입니다.** 사람은
|
|
44
|
+
스위치를 두 번 누르고, 위임장이 둘이면 끌 때 하나만 꺼서는 안 멈춥니다.
|
|
45
|
+
|
|
46
|
+
### 자체 인증을 쓰는 회사라면
|
|
47
|
+
|
|
48
|
+
직원이 우리 로그인을 거치지 않으므로 `principalId` 가 없습니다. 대신 **그 회사
|
|
49
|
+
인증서버가 서명한 진술**을 보냅니다 — "지금 이 버튼을 누른 사람은 우리 직원
|
|
50
|
+
아무개다".
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const { grant, principal } = await identity.grants.ensure({
|
|
54
|
+
agentId,
|
|
55
|
+
principalAssertion: await ourAuthServer.signStatement(currentUser),
|
|
56
|
+
scope: 'sign:create',
|
|
57
|
+
audience: 'https://sign.wegooli.com',
|
|
58
|
+
expiresInDays: 1,
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
이 경우 **같은 호출이 갱신도 겸합니다.** 새 진술을 붙여 다시 부르면 만료일이
|
|
63
|
+
하루 뒤로 밀립니다. 사람이 퇴사해 진술을 못 만들게 되면, 아무도 아무것도 안
|
|
64
|
+
해도 위임장이 하루 안에 스스로 닫힙니다.
|
|
65
|
+
|
|
66
|
+
진술을 만드는 법은
|
|
67
|
+
[외부 주체 위임 연동 계약](https://github.com/wegooli/identity/blob/main/docs/integration/22-외부-주체-위임-연동-계약.md)
|
|
68
|
+
을 보세요.
|
|
69
|
+
|
|
70
|
+
## 끄기
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
await identity.grants.revoke(grant.id, '사용자가 화면에서 껐습니다');
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**다음 검증 시점에 바로** 먹습니다. 이미 나가 있는 토큰도 함께 무효가 됩니다.
|
|
77
|
+
|
|
78
|
+
이유를 남기면 나중에 "왜 멈췄나" 에 답할 수 있습니다 — 사람이 껐는지, 관리자가
|
|
79
|
+
껐는지, 사고 대응이었는지는 전부 다른 이야기입니다.
|
|
80
|
+
|
|
81
|
+
### 껐던 것을 다시 켜려 하면 거절됩니다
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
try {
|
|
85
|
+
await identity.grants.ensure({ /* … */ });
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e instanceof DelegationError && e.isRevokedByPrincipal) {
|
|
88
|
+
// 사람이 껐던 위임이다. 화면에서 다시 동의를 받고 reconsent: true 로 보낸다.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
오류가 아니라 **설계**입니다. 사람이 끈 위임을 백엔드가 조용히 다시 만들면 그
|
|
94
|
+
사람이 누른 스위치는 아무것도 안 한 게 됩니다.
|
|
95
|
+
|
|
96
|
+
## 받은 토큰 검사하기 (리소스 서버 쪽)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { createTokenVerifier } from '@wegooli/identity-delegation';
|
|
100
|
+
|
|
101
|
+
const verifier = createTokenVerifier({
|
|
102
|
+
issuer: 'https://api.freezz.kr',
|
|
103
|
+
audience: 'https://sign.wegooli.com', // 내 서비스 주소. 필수입니다
|
|
104
|
+
introspect: true, // 되돌리기 어려운 동작 앞에서는 켭니다
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const caller = await verifier.verify(request.headers.get('authorization') ?? '');
|
|
108
|
+
if (!caller.has('sign:create')) return forbidden();
|
|
109
|
+
|
|
110
|
+
await createContract({
|
|
111
|
+
createdBy: caller.principalId, // 책임지는 사람
|
|
112
|
+
actorAgentId: caller.agentId, // 실제로 누른 기계
|
|
113
|
+
grantId: caller.grantId, // 무엇이 허락돼 있었나
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 토큰이 담고 있는 것
|
|
118
|
+
|
|
119
|
+
이 토큰은 "누가 불렀는가" 뿐 아니라 **"누구를 대신해서"** 를 담습니다
|
|
120
|
+
(RFC 8693).
|
|
121
|
+
|
|
122
|
+
| | |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `caller.principalId` | 책임 주체 — 토큰의 `sub` |
|
|
125
|
+
| `caller.agentId` | 행위자 — 토큰의 `act.sub` |
|
|
126
|
+
| `caller.email` | 책임 주체의 이메일 |
|
|
127
|
+
| `caller.grantId` / `grantVersion` | 어느 위임장에서 나온 권한인가 |
|
|
128
|
+
|
|
129
|
+
순서가 거꾸로 보이지만 그게 요점입니다. **`sub` 으로 권한을 판단하던 기존
|
|
130
|
+
코드가 그대로 동작합니다.** `act` 는 "사람이 아니라 기계가 했다" 를 기록에
|
|
131
|
+
남기고 싶을 때만 읽으면 됩니다.
|
|
132
|
+
|
|
133
|
+
그리고 그 사람이 우리 로그인 사용자든 고객사 직원이든 **토큰 모양이 같습니다.**
|
|
134
|
+
둘을 구별하는 분기를 쓸 일은 없습니다.
|
|
135
|
+
|
|
136
|
+
### `audience` 는 필수입니다
|
|
137
|
+
|
|
138
|
+
빼면 **다른 서비스용으로 발급된 토큰이 여기서도 통과합니다.** 그러면 위임장의
|
|
139
|
+
"이 토큰은 어디로 갈 수 있다" 는 제한이 아무것도 막지 않게 됩니다.
|
|
140
|
+
|
|
141
|
+
### `introspect` 를 켜는 기준
|
|
142
|
+
|
|
143
|
+
끄면 위임 취소를 토큰 수명(약 5분)만큼 늦게 봅니다. 계약서 발송처럼 되돌리기
|
|
144
|
+
어려운 동작 앞에서는 켜고, 화면 하나 그리는 데는 켜지 않아도 됩니다.
|
|
145
|
+
|
|
146
|
+
네트워크가 끊기면 **통과시킵니다.** 서명과 만료는 이미 확인했고, Identity 가
|
|
147
|
+
잠깐 안 뜬다고 정상 요청이 전부 막히는 편이 더 나쁩니다. 확실히 거절된
|
|
148
|
+
경우(401·403)에만 막습니다.
|
|
149
|
+
|
|
150
|
+
## 내 서비스의 권한 이름 선언하기
|
|
151
|
+
|
|
152
|
+
동의 화면이 그리는 문장은 **그 권한을 가진 서비스가 씁니다.** 우리가 지어내지
|
|
153
|
+
않습니다.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
await identity.resourceServers.declare({
|
|
157
|
+
identifier: 'https://sign.wegooli.com', // 토큰 aud 에 들어갈 값
|
|
158
|
+
displayName: '전자서명',
|
|
159
|
+
scopes: [
|
|
160
|
+
{
|
|
161
|
+
name: 'sign:read',
|
|
162
|
+
displayName: '내 전자서명 문서를 읽습니다',
|
|
163
|
+
description: '제목·상태·만든 날짜를 봅니다. 문서 내용은 보지 않습니다.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: 'sign:create',
|
|
167
|
+
displayName: '내 이름으로 계약을 만들고 보냅니다',
|
|
168
|
+
description: '상대에게 서명 링크가 발송됩니다.',
|
|
169
|
+
isSensitive: true, // 되돌리기 어려운 권한은 화면에서 눈에 띄게 그립니다
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
배포할 때마다 불러도 됩니다 — 주소가 같으면 행이 하나로 유지되고, 선언은
|
|
176
|
+
통째로 바뀝니다. **권한 하나를 목록에서 빼면 그 권한은 더 이상 위임될 수
|
|
177
|
+
없습니다.**
|
|
178
|
+
|
|
179
|
+
`displayName` 은 **1인칭 현재형**으로 씁니다. 읽는 사람은 개발자가 아니라
|
|
180
|
+
"내 이름으로 무슨 일이 벌어지는가" 를 판단하는 사람입니다.
|
|
181
|
+
|
|
182
|
+
## 끄기 화면
|
|
183
|
+
|
|
184
|
+
사람이 자기 화면에서 끄는 조각은 `@wegooli/identity-ui` 의
|
|
185
|
+
`<DelegationManager />` 입니다. 그 컴포넌트는 스스로 네트워크를 쓰지 않고,
|
|
186
|
+
`onRevoke` 를 이 라이브러리의 `grants.revoke()` 에 연결해서 씁니다 —
|
|
187
|
+
비밀 키가 브라우저로 나가지 않게 하려는 것입니다.
|
|
188
|
+
|
|
189
|
+
## 끄는 곳은 두 군데입니다
|
|
190
|
+
|
|
191
|
+
| 누가 | 어디서 | 왜 |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| 본인 | 고객사 화면 (`<DelegationManager />`) | 자기가 아는 화면 |
|
|
194
|
+
| 회사 | Wegooli 대시보드 | **고객사 시스템이 통째로 멈춰도 듣습니다** |
|
|
195
|
+
|
|
196
|
+
두 번째가 이 제품을 쓰는 이유입니다. 첫 번째만 있으면 시스템이 고장 났을 때
|
|
197
|
+
끄는 방법도 같이 고장 납니다.
|
package/dist/index.d.mts
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 };
|