@things-factory/auth-base 10.1.4 → 10.1.7

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist-client/auth.d.ts +9 -1
  3. package/dist-client/auth.js +12 -3
  4. package/dist-client/auth.js.map +1 -1
  5. package/dist-client/tsconfig.tsbuildinfo +1 -1
  6. package/dist-server/controllers/profile.d.ts +1 -0
  7. package/dist-server/controllers/signin.js +33 -12
  8. package/dist-server/controllers/signin.js.map +1 -1
  9. package/dist-server/controllers/unlock-user.js +21 -2
  10. package/dist-server/controllers/unlock-user.js.map +1 -1
  11. package/dist-server/index.js +8 -0
  12. package/dist-server/index.js.map +1 -1
  13. package/dist-server/router/auth-public-process-router.js +32 -5
  14. package/dist-server/router/auth-public-process-router.js.map +1 -1
  15. package/dist-server/service/index.d.ts +3 -1
  16. package/dist-server/service/index.js +15 -11
  17. package/dist-server/service/index.js.map +1 -1
  18. package/dist-server/service/privilege/privilege-directive.d.ts +15 -0
  19. package/dist-server/service/privilege/privilege-directive.js +27 -6
  20. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  21. package/dist-server/service/role/index.d.ts +2 -1
  22. package/dist-server/service/role/index.js +2 -1
  23. package/dist-server/service/role/index.js.map +1 -1
  24. package/dist-server/service/role/role-mutation.d.ts +17 -1
  25. package/dist-server/service/role/role-mutation.js +126 -7
  26. package/dist-server/service/role/role-mutation.js.map +1 -1
  27. package/dist-server/service/role/role-privilege-query.d.ts +14 -0
  28. package/dist-server/service/role/role-privilege-query.js +37 -0
  29. package/dist-server/service/role/role-privilege-query.js.map +1 -0
  30. package/dist-server/service/role/role-query.d.ts +61 -1
  31. package/dist-server/service/role/role-query.js +114 -55
  32. package/dist-server/service/role/role-query.js.map +1 -1
  33. package/dist-server/service/role-template/index.d.ts +4 -0
  34. package/dist-server/service/role-template/index.js +9 -0
  35. package/dist-server/service/role-template/index.js.map +1 -0
  36. package/dist-server/service/role-template/role-template-mutation.d.ts +39 -0
  37. package/dist-server/service/role-template/role-template-mutation.js +124 -0
  38. package/dist-server/service/role-template/role-template-mutation.js.map +1 -0
  39. package/dist-server/service/role-template/role-template-query.d.ts +11 -0
  40. package/dist-server/service/role-template/role-template-query.js +79 -0
  41. package/dist-server/service/role-template/role-template-query.js.map +1 -0
  42. package/dist-server/service/role-template/role-template-types.d.ts +51 -0
  43. package/dist-server/service/role-template/role-template-types.js +71 -0
  44. package/dist-server/service/role-template/role-template-types.js.map +1 -0
  45. package/dist-server/service/role-template/role-template.d.ts +110 -0
  46. package/dist-server/service/role-template/role-template.js +129 -0
  47. package/dist-server/service/role-template/role-template.js.map +1 -0
  48. package/dist-server/service/user/user.d.ts +1 -0
  49. package/dist-server/service/user/user.js +55 -13
  50. package/dist-server/service/user/user.js.map +1 -1
  51. package/dist-server/tsconfig.tsbuildinfo +1 -1
  52. package/dist-server/utils/account-lock-rule.d.ts +79 -0
  53. package/dist-server/utils/account-lock-rule.js +114 -0
  54. package/dist-server/utils/account-lock-rule.js.map +1 -0
  55. package/dist-server/utils/lock-duration.d.ts +3 -0
  56. package/dist-server/utils/lock-duration.js +36 -0
  57. package/dist-server/utils/lock-duration.js.map +1 -0
  58. package/package.json +4 -4
  59. package/tests/account-lock-db.test.ts +216 -0
  60. package/tests/account-lock-rule.test.ts +141 -0
  61. package/tests/compiled.ts +41 -0
  62. package/tests/has-privilege-db.test.ts +198 -0
  63. package/tests/lock-recovery.test.ts +204 -0
  64. package/tests/password-hashing.test.ts +131 -0
  65. package/tests/password-persistence.test.ts +147 -0
  66. package/tests/permission-gate.test.ts +226 -0
  67. package/tests/privilege-directive.test.ts +180 -0
  68. package/tests/role-mutation-db.test.ts +263 -0
  69. package/tests/role-privileges-db.test.ts +226 -0
  70. package/tests/role-template-seed-db.test.ts +179 -0
  71. package/tests/role-template.test.ts +126 -0
  72. package/tests/signin-policy.test.ts +200 -0
  73. package/tests/token-issuance.test.ts +240 -0
  74. package/translations/en.json +161 -1
  75. package/translations/ko.json +161 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Role templates — the registry and what it refuses.
3
+ *
4
+ * The refusals are the interesting part. A template that registers happily and then grants
5
+ * nothing is the quiet failure this design exists to avoid, so the checks that make noise
6
+ * are the ones pinned here.
7
+ */
8
+
9
+ import { loadCompiled } from './compiled'
10
+
11
+ const {
12
+ registerRoleTemplate,
13
+ roleTemplates,
14
+ roleTemplateById,
15
+ computedRoleTemplates,
16
+ allRoleTemplates,
17
+ unknownTemplateGrants
18
+ } = loadCompiled('service/role-template/role-template')
19
+
20
+ beforeEach(() => {
21
+ ;(process as any)['ROLE_TEMPLATES'] = {}
22
+ ;(process as any).PRIVILEGES = {
23
+ 'board query': ['board', 'query'],
24
+ 'board mutation': ['board', 'mutation'],
25
+ 'order query': ['order', 'query'],
26
+ 'worklist query': ['worklist', 'query'],
27
+ 'worklist mutation': ['worklist', 'mutation']
28
+ }
29
+ })
30
+
31
+ describe('declaring a template', () => {
32
+ it('can be found again by id', () => {
33
+ registerRoleTemplate({
34
+ id: 'plant.production-lead',
35
+ name: 'PRODUCTION-LEAD',
36
+ grants: [{ category: 'worklist', axes: ['query', 'mutation'] }]
37
+ })
38
+
39
+ expect(roleTemplateById('plant.production-lead')!.name).toBe('PRODUCTION-LEAD')
40
+ expect(roleTemplates()).toHaveLength(1)
41
+ })
42
+ })
43
+
44
+ describe('what registration refuses', () => {
45
+ it('refuses a template with no grants written at all', () => {
46
+ expect(() => registerRoleTemplate({ id: 'x', name: 'X' } as any)).toThrow(/no grants/)
47
+ })
48
+
49
+ it('accepts an empty list — a seat that opens nothing on purpose, for approval routing', () => {
50
+ registerRoleTemplate({ id: 'twin.quality-inspector', name: 'QUALITY-INSPECTOR', grants: [] })
51
+
52
+ expect(roleTemplateById('twin.quality-inspector')!.grants).toEqual([])
53
+ })
54
+
55
+ it('refuses a grant with no axes', () => {
56
+ expect(() => registerRoleTemplate({ id: 'x', name: 'X', grants: [{ category: 'board', axes: [] }] })).toThrow(
57
+ /no axes/
58
+ )
59
+ })
60
+
61
+ it('refuses the same id twice', () => {
62
+ registerRoleTemplate({ id: 'x', name: 'X', grants: [] })
63
+
64
+ expect(() => registerRoleTemplate({ id: 'x', name: 'Y', grants: [] })).toThrow(/declared twice/)
65
+ })
66
+
67
+ it('accepts the same role name from two applications — one seat, declared twice', () => {
68
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
69
+ registerRoleTemplate({ id: 'twin.qi', name: 'QUALITY-INSPECTOR', grants: [] })
70
+
71
+ expect(roleTemplates().filter(one => one.name === 'QUALITY-INSPECTOR')).toHaveLength(2)
72
+ })
73
+ })
74
+
75
+ describe('names this installation does not declare', () => {
76
+ it('points at grants that would silently open nothing after a category rename', () => {
77
+ registerRoleTemplate({
78
+ id: 'plant.x',
79
+ name: 'X',
80
+ grants: [
81
+ { category: 'board', axes: ['query'] },
82
+ { category: 'board-ai', axes: ['query', 'mutation'] }
83
+ ]
84
+ })
85
+
86
+ expect(unknownTemplateGrants(roleTemplateById('plant.x')!)).toEqual(['board-ai:query', 'board-ai:mutation'])
87
+ })
88
+
89
+ it('reports nothing when every named privilege is installed', () => {
90
+ registerRoleTemplate({ id: 'plant.y', name: 'Y', grants: [{ category: 'order', axes: ['query'] }] })
91
+
92
+ expect(unknownTemplateGrants(roleTemplateById('plant.y')!)).toEqual([])
93
+ })
94
+ })
95
+
96
+ describe('the two templates the framework computes', () => {
97
+ it('gives the viewer every category that declares a query axis', () => {
98
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
99
+
100
+ expect(viewer.grants.map(grant => grant.category).sort()).toEqual(['board', 'order', 'worklist'])
101
+ expect(viewer.grants.every(grant => grant.axes.join() === 'query')).toBe(true)
102
+ })
103
+
104
+ it('gives the administrator everything declared', () => {
105
+ const administrator = computedRoleTemplates().find(one => one.id === 'framework.administrator')!
106
+ const pairs = administrator.grants.flatMap(grant => grant.axes.map(axis => `${grant.category}:${axis}`)).sort()
107
+
108
+ expect(pairs).toEqual(['board:mutation', 'board:query', 'order:query', 'worklist:mutation', 'worklist:query'])
109
+ })
110
+
111
+ it('widens only the administrator when a new axis is declared', () => {
112
+ ;(process as any).PRIVILEGES['board execute'] = ['board', 'execute']
113
+
114
+ const administrator = computedRoleTemplates().find(one => one.id === 'framework.administrator')!
115
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
116
+
117
+ expect(administrator.grants.find(grant => grant.category === 'board')!.axes).toContain('execute')
118
+ expect(viewer.grants.find(grant => grant.category === 'board')!.axes).toEqual(['query'])
119
+ })
120
+
121
+ it('offers the computed two alongside whatever applications declared', () => {
122
+ registerRoleTemplate({ id: 'plant.z', name: 'Z', grants: [] })
123
+
124
+ expect(allRoleTemplates().map(one => one.id)).toEqual(['framework.viewer', 'framework.administrator', 'plant.z'])
125
+ })
126
+ })
@@ -0,0 +1,200 @@
1
+ /**
2
+ * `signin` 의 상태 정책 — 실 DB 로 잠금까지 몰아 본다.
3
+ *
4
+ * 이 경로는 플랫폼에서 사람이 들어오는 **유일한 문**인데 시험이 하나도 없었다. 정책이 코드에
5
+ * 흩어져 있고(실패 셈, 잠금, `status` 분기 넷), 순서가 결과를 바꾼다 — 그래서 읽기만으로는
6
+ * 「이 계정이 왜 못 들어오나」를 말할 수 없다.
7
+ *
8
+ * ── 실제로 부르는 모양을 그대로 쓴다 ────────────────────────────────────────
9
+ * `signinMiddleware:20` 은 `signin({ username, password })` 를 **context 없이** 부른다.
10
+ * 그래서 시험도 context 를 넘기지 않는다 — 넘기면 시험이 화면보다 나은 조건에서 도는 것이 된다.
11
+ * 그 차이가 아래 「잠금 해제 메일」 케이스의 주제다.
12
+ */
13
+
14
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
15
+ import { loadCompiled } from './compiled'
16
+
17
+ const { User } = loadCompiled('service/user/user')
18
+ const { signin } = loadCompiled('controllers/signin')
19
+ const { VerificationToken } = loadCompiled('service/verification-token/verification-token')
20
+ /* 오류 코드는 문자열이고 표기가 제각각이다(`'user not found'` · `'password-not-matched'`).
21
+ 시험이 그 표기를 베껴 쓰면 코드를 고칠 때 시험만 조용히 틀린다 — 상수를 그대로 든다. */
22
+ const CODES = loadCompiled('constants/error-code')
23
+
24
+ const PASSWORD = 'Passw0rd!'
25
+ const WRONG = 'Wr0ngPass!'
26
+
27
+ let domain: any
28
+
29
+ async function seedUser(overrides: any = {}) {
30
+ const salt = User.generateSalt()
31
+
32
+ return await getRepository(User).save({
33
+ username: 'kim',
34
+ email: 'kim@example.com',
35
+ name: 'Kim',
36
+ salt,
37
+ password: User.encode(PASSWORD, salt),
38
+ status: 'activated',
39
+ domains: [domain],
40
+ ...overrides
41
+ })
42
+ }
43
+
44
+ /** 실 경로와 같게 — context 없이 부른다. */
45
+ const attempt = (password: string, username = 'kim') => signin({ username, password })
46
+
47
+ async function reload(id: string) {
48
+ return await getRepository(User).findOneBy({ id })
49
+ }
50
+
51
+ beforeAll(async () => {
52
+ await openAuthDatabase()
53
+ })
54
+
55
+ beforeEach(async () => {
56
+ await resetAuthDatabase()
57
+ domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
58
+ })
59
+
60
+ afterAll(async () => {
61
+ await closeAuthDatabase()
62
+ })
63
+
64
+ describe('들어가기', () => {
65
+ it('맞는 비밀번호로 토큰과 소속 도메인을 받는다', async () => {
66
+ const user = await seedUser()
67
+
68
+ const result = await attempt(PASSWORD)
69
+
70
+ expect(result.token).toBeTruthy()
71
+ expect(result.user.id).toBe(user.id)
72
+ expect(result.domains.map((d: any) => d.subdomain)).toEqual(['plant-a'])
73
+ })
74
+
75
+ it('username 이 아니라 email 로도 들어간다', async () => {
76
+ await seedUser()
77
+
78
+ const result = await attempt(PASSWORD, 'kim@example.com')
79
+
80
+ expect(result.token).toBeTruthy()
81
+ })
82
+ })
83
+
84
+ describe('실패 셈과 잠금', () => {
85
+ it('틀릴 때마다 셈이 하나 오른다', async () => {
86
+ const user = await seedUser()
87
+
88
+ for (const expected of [1, 2, 3, 4]) {
89
+ await expect(attempt(WRONG)).rejects.toThrow()
90
+ expect((await reload(user.id)).failCount).toBe(expected)
91
+ }
92
+
93
+ /* 4회까지는 아직 열려 있다. */
94
+ expect((await reload(user.id)).status).toBe('activated')
95
+ })
96
+
97
+ it('다섯 번째에 잠긴다', async () => {
98
+ const user = await seedUser()
99
+
100
+ for (let i = 0; i < 5; i++) {
101
+ await expect(attempt(WRONG)).rejects.toThrow()
102
+ }
103
+
104
+ const locked = await reload(user.id)
105
+ expect(locked.status).toBe('locked')
106
+ expect(locked.failCount).toBe(5)
107
+ })
108
+
109
+ it('잠긴 뒤에는 **맞는 비밀번호도** 거절한다', async () => {
110
+ /* 잠금 검사가 비밀번호 대조보다 먼저 온다(signin.ts:39). 그래서 남이 남의 계정을
111
+ 잠글 수 있고, 잠긴 뒤 본인도 못 들어온다 — 지금 정책이 그렇다. */
112
+ const user = await seedUser({ status: 'locked' })
113
+
114
+ await expect(attempt(PASSWORD)).rejects.toThrow()
115
+ expect((await reload(user.id)).status).toBe('locked')
116
+ })
117
+
118
+ it('성공하면 셈이 0 으로 돌아간다', async () => {
119
+ const user = await seedUser()
120
+
121
+ await expect(attempt(WRONG)).rejects.toThrow()
122
+ await expect(attempt(WRONG)).rejects.toThrow()
123
+ expect((await reload(user.id)).failCount).toBe(2)
124
+
125
+ await attempt(PASSWORD)
126
+ expect((await reload(user.id)).failCount).toBe(0)
127
+ })
128
+ })
129
+
130
+ describe('상태 분기', () => {
131
+ it('삭제된 계정은 비밀번호를 보기도 전에 거절한다', async () => {
132
+ const user = await seedUser({ status: 'deleted' })
133
+
134
+ await expect(attempt(PASSWORD)).rejects.toThrow()
135
+ /* 셈이 오르지 않는다 — 비밀번호 대조 앞에서 끊긴다. */
136
+ expect((await reload(user.id)).failCount).toBe(0)
137
+ })
138
+
139
+ it('미활성 계정은 비밀번호를 **맞춘 뒤에** 거절한다', async () => {
140
+ /* 순서가 뜻을 만든다: 틀리면 「비밀번호가 틀렸다」, 맞히면 「활성화되지 않았다」.
141
+ 즉 미활성 계정에 대해 비밀번호 맞음/틀림이 응답으로 갈린다. */
142
+ const user = await seedUser({ status: 'inactive' })
143
+
144
+ await expect(attempt(WRONG)).rejects.toMatchObject({ errorCode: CODES.PASSWORD_NOT_MATCHED })
145
+ await expect(attempt(PASSWORD)).rejects.toMatchObject({ errorCode: CODES.USER_NOT_ACTIVATED })
146
+ })
147
+
148
+ it('없는 계정과 틀린 비밀번호가 다른 오류 코드로 갈린다', async () => {
149
+ await seedUser()
150
+
151
+ await expect(attempt(PASSWORD, 'nobody')).rejects.toMatchObject({ errorCode: CODES.USER_NOT_FOUND })
152
+ await expect(attempt(WRONG)).rejects.toMatchObject({ errorCode: CODES.PASSWORD_NOT_MATCHED })
153
+ })
154
+ })
155
+
156
+ describe('실패 응답에 실려 나가는 것', () => {
157
+ it('인증되지 않은 실패가 email 과 남은 시도 횟수를 함께 싣는다', async () => {
158
+ /* `detail` 이 화면까지 가는 경로가 있다. 아무 자격 없이 세 가지를 알 수 있다 —
159
+ 그 계정이 있다는 것, 그 계정의 email, 그리고 잠기기까지 몇 번 남았는지. */
160
+ await seedUser()
161
+
162
+ await expect(attempt(WRONG)).rejects.toMatchObject({
163
+ detail: { username: 'kim', email: 'kim@example.com', failCount: 1 }
164
+ })
165
+ })
166
+ })
167
+
168
+ describe('잠금 해제 메일', () => {
169
+ it('잠기면 해제 토큰이 만들어진다', async () => {
170
+ /*
171
+ * ⚠ **부르는 쪽이 `await` 하지 않는다** — `signin.ts:40,58` 이 `sendUnlockUserEmail(...)` 을
172
+ * 띄워 놓고 곧바로 던진다. 그래서 시험이 바로 읽으면 **0건이 나온다.** 그 0건은 제품의
173
+ * 사실이 아니라 아직 도착하지 않은 쓰기다(처음에 그것을 「메일이 안 간다」로 읽었다).
174
+ *
175
+ * 여기서는 그 성질 자체를 고정한다: 즉시 읽으면 없고, 기다리면 있다. 언젠가 그 호출에
176
+ * `await` 가 붙으면 앞의 기대가 깨져서 알려 줄 것이다.
177
+ *
178
+ * 메일 발송 자체는 이 하네스로 확인하지 않는다 — `sendEmail` 이 `config.get('email')` 을
179
+ * 읽는데 시험 환경에는 그 설정이 없다(`emailInfo.sender` 에서 던진다). 발송은 운영 설정의
180
+ * 일이고, 실제로 도착하고 있음을 사용자가 확인했다.
181
+ */
182
+ const user = await seedUser()
183
+
184
+ for (let i = 0; i < 5; i++) {
185
+ await expect(attempt(WRONG)).rejects.toThrow()
186
+ }
187
+
188
+ expect((await reload(user.id)).status).toBe('locked')
189
+
190
+ /* 던진 직후에는 아직 없다 — 부르는 쪽이 기다리지 않기 때문이다. */
191
+ expect(await getRepository(VerificationToken).find()).toHaveLength(0)
192
+
193
+ await new Promise(resolve => setTimeout(resolve, 300))
194
+
195
+ const tokens = await getRepository(VerificationToken).find()
196
+ expect(tokens).toHaveLength(1)
197
+ expect(tokens[0].type).toBe('unlock')
198
+ expect(tokens[0].userId).toBe(user.id)
199
+ })
200
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * 토큰 발급 시험 — **우리 토큰이 무엇을 말하는가.**
3
+ *
4
+ * 사람·애플리케이션·어플라이언스 세 종류의 주체가 모두 JWT 로 들어온다. 저장소의 시험 316개
5
+ * 중 발급을 부르는 것이 없었다. 여기서 고정하는 것은 「무엇이 실려 있나」와 「무엇이 실려
6
+ * 있지 않나」다. **없는 것 쪽이 더 중요하다** (`docs/design/auth-rebuild.md` §4):
7
+ *
8
+ * · `jti` 가 없다 → 발급한 토큰을 **가리킬 수 없다** = 폐기 수단이 없다.
9
+ * 로그아웃은 쿠키만 지우고, 복사된 토큰은 만료까지 살아 있다.
10
+ * · `kid` 가 없다 → 두 키를 동시에 신뢰할 수 없다 = 서명 키를 **교체할 수 없다**.
11
+ * · 사람 토큰에 도메인이 없다 → 도메인은 `x-things-factory-domain` 헤더가 정한다.
12
+ *
13
+ * 폐기·회전을 넣을 때 이 시험들이 먼저 깨져야 한다. 「`jti` 가 없다」가 깨지는 것이 곧
14
+ * 대장이 생겼다는 증거다.
15
+ */
16
+
17
+ import jwt from 'jsonwebtoken'
18
+
19
+ import { loadCompiled } from './compiled'
20
+
21
+ const { User } = loadCompiled('service/user/user')
22
+ const { Appliance } = loadCompiled('service/appliance/appliance')
23
+ const { Application } = loadCompiled('service/application/application')
24
+ const { SECRET } = loadCompiled('utils/get-secret')
25
+
26
+ const DAY = 24 * 60 * 60
27
+ const YEAR = 365.25 * DAY /* jsonwebtoken 의 `ms` 는 '1y' 를 365.25일로 읽는다 */
28
+
29
+ const DOMAIN = { id: 'domain-1', subdomain: 'plant-a' }
30
+ const APPLIANCE = { id: 'appliance-1', name: 'gate-scanner' }
31
+
32
+ function decode(token: string) {
33
+ return jwt.decode(token, { complete: true }) as any
34
+ }
35
+
36
+ function lifetime(payload: any) {
37
+ return payload.exp - payload.iat
38
+ }
39
+
40
+ function personToken(overrides: any = {}) {
41
+ const user = new User()
42
+ Object.assign(user, { id: 'user-1', username: 'kim', email: 'kim@example.com', ...overrides })
43
+ return user.sign()
44
+ }
45
+
46
+ describe('사람 토큰 (User.sign)', () => {
47
+ it('실려 있는 것은 username · email 과 표준 클레임뿐이다', async () => {
48
+ const { payload } = decode(await personToken())
49
+
50
+ expect(Object.keys(payload).sort()).toEqual(['email', 'exp', 'iat', 'iss', 'sub', 'username'])
51
+ expect(payload).toMatchObject({
52
+ username: 'kim',
53
+ email: 'kim@example.com',
54
+ iss: 'hatiolab.com',
55
+ sub: 'user'
56
+ })
57
+ })
58
+
59
+ it('username 이 없으면 email 을 username 자리에 넣는다', async () => {
60
+ const { payload } = decode(await personToken({ username: undefined }))
61
+
62
+ expect(payload.username).toBe('kim@example.com')
63
+ })
64
+
65
+ it('사용자 id 를 싣지 않는다 — 토큰은 username 으로 사람을 가리킨다', async () => {
66
+ const { payload } = decode(await personToken())
67
+
68
+ expect(payload.id).toBeUndefined()
69
+ })
70
+
71
+ it('기본 수명은 7일이다', async () => {
72
+ const { payload } = decode(await personToken())
73
+
74
+ expect(lifetime(payload)).toBe(7 * DAY)
75
+ })
76
+
77
+ it('넘긴 도메인은 버려진다 — 토큰에 도메인이 없다', async () => {
78
+ /* `signin` 은 `user.sign({ subdomain: domain?.subdomain })` 로 부르는데(signin.ts:95)
79
+ `sign` 은 `expiresIn` 만 꺼내 쓴다(user.ts:186). 넘긴 값이 조용히 사라진다. */
80
+ const user = new User()
81
+ Object.assign(user, { id: 'user-1', username: 'kim', email: 'kim@example.com' })
82
+
83
+ const { payload } = decode(await user.sign({ subdomain: 'plant-a' }))
84
+
85
+ expect(payload.subdomain).toBeUndefined()
86
+ expect(payload.domain).toBeUndefined()
87
+ })
88
+ })
89
+
90
+ describe('어플라이언스 토큰 (Appliance.generateAccessToken)', () => {
91
+ it('주체 종류와 어플라이언스 id, 도메인을 싣는다', () => {
92
+ const { payload } = decode(Appliance.generateAccessToken(DOMAIN, { id: 'appuser-1' }, APPLIANCE))
93
+
94
+ expect(payload).toMatchObject({
95
+ id: 'appuser-1',
96
+ userType: 'appliance',
97
+ appliance: { id: 'appliance-1' },
98
+ status: 'activated',
99
+ domain: { subdomain: 'plant-a' },
100
+ sub: 'access-token'
101
+ })
102
+ })
103
+
104
+ it('기본 수명이 1년이다', () => {
105
+ /* 설정 `applianceJwtExpiresIn` 이 없을 때의 값(appliance.ts:131).
106
+ 이 토큰은 DB 에 평문으로도 남는다(appliance-mutation.ts:64,70). */
107
+ const { payload } = decode(Appliance.generateAccessToken(DOMAIN, { id: 'appuser-1' }, APPLIANCE))
108
+
109
+ expect(lifetime(payload)).toBe(YEAR)
110
+ })
111
+ })
112
+
113
+ describe('애플리케이션 토큰 (Application)', () => {
114
+ const SCOPES = ['reader', 'approver']
115
+
116
+ it('access token 은 30일, refresh token 은 1년이다', () => {
117
+ const access = decode(Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', SCOPES))
118
+ const refresh = decode(Application.generateRefreshToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', SCOPES))
119
+
120
+ expect(lifetime(access.payload)).toBe(30 * DAY)
121
+ expect(lifetime(refresh.payload)).toBe(YEAR)
122
+ expect(access.payload.sub).toBe('access-token')
123
+ expect(refresh.payload.sub).toBe('refresh-token')
124
+ })
125
+
126
+ it('appKey 와 scope 를 싣는다 — scope 는 그 도메인의 역할 이름이다', () => {
127
+ /* `oauth2-server.ts:131` 이 `roles: scopes` 로 저장하고 `:240` 이 `name: In(scopes)` 로
128
+ 역할을 찾는다. 즉 scope 어휘가 따로 없고 역할 이름이 곧 scope 다 — 역할 이름을 바꾸면
129
+ 이미 발급된 토큰의 scope 가 조용히 뜻을 잃는다. */
130
+ const { payload } = decode(Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', SCOPES))
131
+
132
+ expect(payload).toMatchObject({
133
+ userType: 'application',
134
+ application: { appKey: 'APPKEY' },
135
+ scope: SCOPES
136
+ })
137
+ })
138
+
139
+ it('auth code 는 1분 살고 왕복한다', () => {
140
+ const code = Application.generateAuthCode('kim@example.com', 'APPKEY', 'plant-a', ['reader'], 'state-1')
141
+ const { payload } = decode(code)
142
+
143
+ expect(lifetime(payload)).toBe(60)
144
+ expect(Application.verifyAuthCode(code)).toMatchObject({
145
+ email: 'kim@example.com',
146
+ appKey: 'APPKEY',
147
+ subdomain: 'plant-a',
148
+ scopes: ['reader'],
149
+ state: 'state-1'
150
+ })
151
+ })
152
+
153
+ it('appKey 와 appSecret 은 16바이트 hex 다', () => {
154
+ expect(Application.generateAppKey()).toMatch(/^[0-9a-f]{32}$/)
155
+ expect(Application.generateAppSecret()).toMatch(/^[0-9a-f]{32}$/)
156
+ expect(Application.generateAppKey()).not.toBe(Application.generateAppKey())
157
+ })
158
+ })
159
+
160
+ describe('`iat` 은 초로 절삭된다 — 밀리초 시각과 견주면 새 토큰이 옛것으로 보인다', () => {
161
+ /*
162
+ * 폐기를 「무효 목록 없이」 하려는 설계가 반드시 밟는 자리다. 사람 행에는
163
+ * `passwordUpdatedAt`(밀리초를 가진 `Date`)이 있고, 토큰에는 `iat`(초)이 있다. 그래서
164
+ * `decoded.iat * 1000 < user.passwordUpdatedAt` 로 「그 시각 앞에 발급된 토큰을 끊는다」를
165
+ * 만들면, **비밀번호를 바꾼 그 사람의 새 토큰까지 끊긴다** — `change-pwd.ts` 가 같은 초에
166
+ * 칸을 쓰고(:81) 토큰을 발급하기(:99) 때문이다.
167
+ *
168
+ * 대장(발급 기록)을 두면 안 생기는 문제이므로, 이 시험은 고칠 대상이 아니라
169
+ * **설계가 이 사실을 알고 있어야 한다는 표시**다.
170
+ */
171
+ /*
172
+ * ⚠ 이 시험은 **시계에 기대고 있었고 6번에 1번쯤 빨갛게 났다** (2026-09-10).
173
+ *
174
+ * 30번을 7ms 간격으로 돌면서 「전부 옛것으로 보인다」를 단정했는데, 그 단정이 틀리는 순간이
175
+ * 둘 있었다. 둘 다 결함이 아니라 **관측이 성립하지 않은 회차**다.
176
+ *
177
+ * 밀리초가 정확히 000 절삭이 아무것도 안 깎는다 — 그 한 순간만 비교가 우연히 안전하다
178
+ * 두 줄 사이에 초가 넘어감 `passwordUpdatedAt` 과 토큰이 **다른 초**다 — 전제가 「같은 초」다
179
+ *
180
+ * 그래서 회차를 세는 대신 **관측이 성립한 회차만** 보고, 그런 회차가 하나도 없으면 조용히
181
+ * 초록이 되지 않게 따로 단정한다. 시계를 목으로 바꾸지 않는 이유는 재는 대상이 **발급기가
182
+ * 실제로 넣는 `iat`** 이기 때문이다.
183
+ */
184
+ it('같은 초에 찍은 밀리초 시각보다 새 토큰의 `iat` 이 앞선다', async () => {
185
+ const ROUNDS = 30
186
+ let observed = 0
187
+ let looksOlder = 0
188
+
189
+ for (let round = 0; round < ROUNDS; round++) {
190
+ /* change-pwd.ts 의 순서: 먼저 `passwordUpdatedAt` 을 쓰고, 그 다음 발급한다. */
191
+ const passwordUpdatedAt = new Date()
192
+ const { payload } = decode(await personToken())
193
+
194
+ const sameSecond = Math.floor(passwordUpdatedAt.getTime() / 1000) === payload.iat
195
+ const truncates = passwordUpdatedAt.getMilliseconds() !== 0
196
+
197
+ if (sameSecond && truncates) {
198
+ observed++
199
+ if (payload.iat * 1000 < passwordUpdatedAt.getTime()) looksOlder++
200
+ }
201
+
202
+ await new Promise(resolve => setTimeout(resolve, 7))
203
+ }
204
+
205
+ /* 성립한 회차가 없으면 위 단정이 「0 === 0」으로 통과한다 — 그것은 초록이 아니다. */
206
+ expect(observed).toBeGreaterThan(0)
207
+ expect(looksOlder).toBe(observed)
208
+ })
209
+ })
210
+
211
+ describe('세 종류의 토큰이 공유하는 것 — 그리고 없는 것', () => {
212
+ /** 사람·어플라이언스·애플리케이션·auth code 를 한 자리에 모아 같은 질문을 던진다. */
213
+ async function everyToken(): Promise<[string, string][]> {
214
+ return [
215
+ ['사람', await personToken()],
216
+ ['어플라이언스', Appliance.generateAccessToken(DOMAIN, { id: 'appuser-1' }, APPLIANCE)],
217
+ ['애플리케이션', Application.generateAccessToken(DOMAIN, { id: 'appuser-1' }, 'APPKEY', ['reader'])],
218
+ ['auth code', Application.generateAuthCode('kim@example.com', 'APPKEY', 'plant-a', ['reader'], 's')]
219
+ ]
220
+ }
221
+
222
+ it('어느 토큰에도 `jti` 가 없다 — 발급한 것을 가리킬 수 없으니 폐기할 수 없다', async () => {
223
+ for (const [kind, token] of await everyToken()) {
224
+ expect({ kind, jti: decode(token).payload.jti }).toEqual({ kind, jti: undefined })
225
+ }
226
+ })
227
+
228
+ it('어느 토큰에도 `kid` 가 없다 — 서명 키를 교체할 수 없다', async () => {
229
+ for (const [kind, token] of await everyToken()) {
230
+ expect({ kind, kid: decode(token).header.kid }).toEqual({ kind, kid: undefined })
231
+ }
232
+ })
233
+
234
+ it('모든 토큰이 같은 대칭키로 HS256 서명된다 — 검증하는 곳은 서명도 할 수 있다', async () => {
235
+ for (const [kind, token] of await everyToken()) {
236
+ expect({ kind, alg: decode(token).header.alg }).toEqual({ kind, alg: 'HS256' })
237
+ expect(() => jwt.verify(token, SECRET)).not.toThrow()
238
+ }
239
+ })
240
+ })