@point3/logto-module 1.0.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.
Files changed (77) hide show
  1. package/README.md +224 -0
  2. package/client/__tests__/m2m-client.spec.ts +60 -0
  3. package/client/__tests__/oauth-client.spec.ts +43 -0
  4. package/client/config.ts +79 -0
  5. package/client/index.ts +5 -0
  6. package/client/logto-login-session.ts +239 -0
  7. package/client/m2m-client.ts +428 -0
  8. package/client/oauth-client.ts +231 -0
  9. package/client/types.ts +136 -0
  10. package/dist/client/__tests__/m2m-client.spec.d.ts +1 -0
  11. package/dist/client/__tests__/m2m-client.spec.js +55 -0
  12. package/dist/client/__tests__/m2m-client.spec.js.map +1 -0
  13. package/dist/client/__tests__/oauth-client.spec.d.ts +1 -0
  14. package/dist/client/__tests__/oauth-client.spec.js +40 -0
  15. package/dist/client/__tests__/oauth-client.spec.js.map +1 -0
  16. package/dist/client/config.d.ts +21 -0
  17. package/dist/client/config.js +16 -0
  18. package/dist/client/config.js.map +1 -0
  19. package/dist/client/index.d.ts +5 -0
  20. package/dist/client/index.js +22 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/logto-login-session.d.ts +28 -0
  23. package/dist/client/logto-login-session.js +128 -0
  24. package/dist/client/logto-login-session.js.map +1 -0
  25. package/dist/client/m2m-client.d.ts +34 -0
  26. package/dist/client/m2m-client.js +201 -0
  27. package/dist/client/m2m-client.js.map +1 -0
  28. package/dist/client/oauth-client.d.ts +25 -0
  29. package/dist/client/oauth-client.js +135 -0
  30. package/dist/client/oauth-client.js.map +1 -0
  31. package/dist/client/types.d.ts +45 -0
  32. package/dist/client/types.js +37 -0
  33. package/dist/client/types.js.map +1 -0
  34. package/dist/errors.d.ts +24 -0
  35. package/dist/errors.js +62 -0
  36. package/dist/errors.js.map +1 -0
  37. package/dist/index.d.ts +11 -0
  38. package/dist/index.js +47 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/module.d.ts +4 -0
  41. package/dist/module.js +47 -0
  42. package/dist/module.js.map +1 -0
  43. package/dist/stateless/decorator.d.ts +7 -0
  44. package/dist/stateless/decorator.js +10 -0
  45. package/dist/stateless/decorator.js.map +1 -0
  46. package/dist/stateless/guard.d.ts +10 -0
  47. package/dist/stateless/guard.js +102 -0
  48. package/dist/stateless/guard.js.map +1 -0
  49. package/dist/stateless/guard.spec.d.ts +1 -0
  50. package/dist/stateless/guard.spec.js +210 -0
  51. package/dist/stateless/guard.spec.js.map +1 -0
  52. package/dist/stateless/index.d.ts +2 -0
  53. package/dist/stateless/index.js +19 -0
  54. package/dist/stateless/index.js.map +1 -0
  55. package/dist/token/access-token.d.ts +31 -0
  56. package/dist/token/access-token.js +19 -0
  57. package/dist/token/access-token.js.map +1 -0
  58. package/dist/token/index.d.ts +2 -0
  59. package/dist/token/index.js +19 -0
  60. package/dist/token/index.js.map +1 -0
  61. package/dist/token/verifier.d.ts +13 -0
  62. package/dist/token/verifier.js +66 -0
  63. package/dist/token/verifier.js.map +1 -0
  64. package/dist/tsconfig.tsbuildinfo +1 -0
  65. package/errors.ts +58 -0
  66. package/index.ts +13 -0
  67. package/jest.config.js +6 -0
  68. package/module.ts +85 -0
  69. package/package.json +33 -0
  70. package/stateless/decorator.ts +16 -0
  71. package/stateless/guard.spec.ts +305 -0
  72. package/stateless/guard.ts +76 -0
  73. package/stateless/index.ts +2 -0
  74. package/token/access-token.ts +48 -0
  75. package/token/index.ts +2 -0
  76. package/token/verifier.ts +101 -0
  77. package/tsconfig.json +23 -0
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@point3/logto-module",
3
+ "version": "1.0.0",
4
+ "description": "포인트3 내부 logto Authentication 모듈입니다",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "build": "tsc --build"
9
+ },
10
+ "author": "point3 개발팀",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "@nestjs/common": "^11.1.3",
14
+ "@nestjs/config": "^4.0.2",
15
+ "@nestjs/core": "^11.1.3",
16
+ "@nestjs/testing": "^11.1.3",
17
+ "axios": "^1.9.0",
18
+ "jest": "^30.0.0",
19
+ "jose": "^6.0.11",
20
+ "point3-common-tool": "^1.0.13"
21
+ },
22
+ "devDependencies": {
23
+ "@types/jest": "^29.5.14"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/violetpay-org/point3-logto-module.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/violetpay-org/point3-logto-module/issues"
31
+ },
32
+ "homepage": "https://github.com/violetpay-org/point3-logto-module#readme"
33
+ }
@@ -0,0 +1,16 @@
1
+ import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
2
+ import { LogtoTokenGuard } from './guard';
3
+
4
+ type LogtoProtectedOptions<T> = Partial<{
5
+ requiredScopes: string[],
6
+ requiredRoles: (T | 'management-point3')[]
7
+ }>
8
+
9
+ export function LogtoProtected<RoleType>(options?: LogtoProtectedOptions<RoleType>): ReturnType<typeof applyDecorators> {
10
+ const { requiredScopes, requiredRoles } = options ?? {};
11
+ return applyDecorators(
12
+ UseGuards(LogtoTokenGuard),
13
+ SetMetadata('requiredScopes', requiredScopes && requiredScopes.length > 0 ? requiredScopes : undefined),
14
+ SetMetadata('requiredRoles', requiredRoles && requiredRoles.length > 0 ? requiredRoles : undefined),
15
+ );
16
+ }
@@ -0,0 +1,305 @@
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { ExecutionContext, LoggerService, UnauthorizedException } from '@nestjs/common';
3
+ import { Reflector } from '@nestjs/core';
4
+
5
+ import { LogtoTokenGuard } from './guard';
6
+ import {
7
+ LogtoTokenVerifier, LogtoTokenVerifierToken,
8
+ AccessToken, AccessTokenPayload
9
+ } from '../token';
10
+
11
+ import { p3Values } from 'point3-common-tool';
12
+ import { LogtoLoggerServiceToken } from 'client';
13
+
14
+ describe('LogtoTokenGuard 테스트', () => {
15
+ let guard: LogtoTokenGuard;
16
+ let tokenUtil: jest.Mocked<LogtoTokenVerifier>;
17
+ let reflector: jest.Mocked<Reflector>;
18
+ let logger: jest.Mocked<LoggerService>;
19
+
20
+ // 사용자가 제공한 실제 JWT 토큰
21
+ const testToken = 'eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImxKUjU3SkFqVmV1dHk4eWljVzUtdFFySDM2WFl6NUlzWFhXSDVzeXV0dEEifQ.eyJ1c2VyUm9sZXMiOlsicDMtQ0lTTy0wIl0sIm1hbmFnZXJJZCI6Im1hbmFnZXItMDE5NjQ0NWMtOGVjNy03MDc4LWExNDItNGU3ZGI5YTRhYWVhIiwiY2xpZW50SWQiOiJwb2ludDMtMDE5NjNjODUtNDQ2ZS03NGM5LWFmNzktNDhlMjU0NjVjMzI3IiwianRpIjoiV0RYTmxoTWkwT0tHQ1pTRzFKZnBrIiwic3ViIjoieXVsaXVmdHNvMWQwIiwiaWF0IjoxNzQ5MDI0NzIzLCJleHAiOjE3NDkwMjgzMjMsInNjb3BlIjoiIiwiY2xpZW50X2lkIjoiNXFydmk5eW0wajJ0YTJ6YXBnbHU0IiwiaXNzIjoiaHR0cHM6Ly9sb2d0by5wb2ludDMuaW8vb2lkYyIsImF1ZCI6Imh0dHBzOi8vZGVmYXVsdC5sb2d0by5hcHAvYXBpIn0.nZdzvdxQ74m2oFEklVTfQlcqYBkRrRxtHQEgz1L6DjST9_9Wa7H7J1gKJVEjm8NnjFCQXljYM_hTVx1ABTmUgDrEKVjtHFVKUyPoSzxQitXexwmBZY5l8WdyqJDqAy8d';
22
+
23
+ // 토큰 데이터와 일치하는 모의 페이로드
24
+ const mockPayload: AccessTokenPayload = {
25
+ userRoles: ['p3-CISO-0'],
26
+ managerId: 'manager-0196445c-8ec7-7078-a142-4e7db9a4aaea',
27
+ clientId: 'point3-019663c85-446e-74c9-af79-48e25465c327',
28
+ jti: 'WDXNlhMi0OKGCZSG1Jfpk',
29
+ sub: 'yuliuftso1d0',
30
+ iat: 1749024723,
31
+ exp: 1749028323,
32
+ scope: '',
33
+ client_id: '5qrvi9ym0j2ta2zapglu4',
34
+ iss: 'https://logto.point3.io/oidc',
35
+ aud: 'https://default.logto.app/api'
36
+ };
37
+
38
+ beforeEach(async () => {
39
+ const mockTokenUtil = {
40
+ verifyToken: jest.fn(),
41
+ };
42
+
43
+ const mockReflector = {
44
+ get: jest.fn(),
45
+ };
46
+
47
+ const mockLogger = {
48
+ warn: jest.fn(),
49
+ error: jest.fn(),
50
+ log: jest.fn(),
51
+ };
52
+
53
+ const module: TestingModule = await Test.createTestingModule({
54
+ providers: [
55
+ LogtoTokenGuard,
56
+ {
57
+ provide: LogtoTokenVerifierToken,
58
+ useValue: mockTokenUtil,
59
+ },
60
+ {
61
+ provide: Reflector,
62
+ useValue: mockReflector,
63
+ },
64
+ {
65
+ provide: LogtoLoggerServiceToken,
66
+ useValue: mockLogger,
67
+ },
68
+ ],
69
+ }).compile();
70
+
71
+ guard = module.get<LogtoTokenGuard>(LogtoTokenGuard);
72
+ tokenUtil = module.get(LogtoTokenVerifierToken);
73
+ reflector = module.get(Reflector);
74
+ logger = module.get(LogtoLoggerServiceToken);
75
+
76
+ // 각 테스트 전에 모의 함수 초기화
77
+ jest.clearAllMocks();
78
+ });
79
+
80
+ const createMockExecutionContext = (headers: any = {}, route: any = { path: '/test' }): ExecutionContext => {
81
+ const mockRequest = {
82
+ headers,
83
+ route,
84
+ user: undefined // Guard에서 설정됨
85
+ };
86
+
87
+ return {
88
+ switchToHttp: () => ({
89
+ getRequest: () => mockRequest,
90
+ getResponse: jest.fn(),
91
+ getNext: jest.fn(),
92
+ }),
93
+ getHandler: jest.fn(),
94
+ getClass: jest.fn(),
95
+ getArgs: jest.fn(),
96
+ getArgByIndex: jest.fn(),
97
+ switchToRpc: jest.fn(),
98
+ switchToWs: jest.fn(),
99
+ getType: jest.fn(),
100
+ } as ExecutionContext;
101
+ };
102
+
103
+ describe('🔐 성공적인 인증 테스트', () => {
104
+ it('유효한 토큰이 제공되었을 때 인증하고 사용자 데이터를 설정해야 함', async () => {
105
+ // 준비
106
+ const context = createMockExecutionContext({
107
+ authorization: `Bearer ${testToken}`,
108
+ });
109
+
110
+ // Reflector 모의 설정 - 특정 역할 요구사항
111
+ reflector.get
112
+ .mockReturnValueOnce(undefined) // requiredScopes
113
+ .mockReturnValueOnce(['p3-CISO-0']); // requiredRoles
114
+
115
+ // 성공적인 토큰 검증 모의
116
+ tokenUtil.verifyToken.mockResolvedValueOnce(mockPayload);
117
+
118
+ // 실행
119
+ const result = await guard.canActivate(context);
120
+ const request = context.switchToHttp().getRequest();
121
+
122
+ // 검증
123
+ expect(result).toBe(true);
124
+ expect(tokenUtil.verifyToken).toHaveBeenCalledWith(
125
+ testToken,
126
+ undefined,
127
+ ['p3-CISO-0']
128
+ );
129
+
130
+ // 사용자 데이터가 올바르게 설정되었는지 확인
131
+ expect(request.user).toEqual({
132
+ userId: 'yuliuftso1d0',
133
+ managerId: expect.objectContaining({
134
+ toString: expect.any(Function)
135
+ }),
136
+ clientId: expect.objectContaining({
137
+ toString: expect.any(Function)
138
+ }),
139
+ });
140
+
141
+ // GUID 값 검증
142
+ expect(request.user.managerId.toString()).toContain('manager');
143
+ expect(request.user.managerId.toString()).toContain('0196445c-8ec7-7078-a142-4e7db9a4aaea');
144
+ expect(request.user.clientId.toString()).toContain('point3');
145
+ expect(request.user.clientId.toString()).toContain('019663c85-446e-74c9-af79-48e25465c327');
146
+ });
147
+
148
+ it('필수 스코프나 역할이 없을 때도 동작해야 함', async () => {
149
+ // 준비
150
+ const context = createMockExecutionContext({
151
+ authorization: `Bearer ${testToken}`,
152
+ });
153
+
154
+ // Reflector 모의 설정 - 요구사항 없음
155
+ reflector.get
156
+ .mockReturnValueOnce(undefined) // requiredScopes
157
+ .mockReturnValueOnce(undefined); // requiredRoles
158
+
159
+ // 성공적인 토큰 검증 모의
160
+ tokenUtil.verifyToken.mockResolvedValueOnce(mockPayload);
161
+
162
+ // 실행
163
+ const result = await guard.canActivate(context);
164
+
165
+ // 검증
166
+ expect(result).toBe(true);
167
+ expect(tokenUtil.verifyToken).toHaveBeenCalledWith(
168
+ testToken,
169
+ undefined,
170
+ undefined
171
+ );
172
+ });
173
+ });
174
+
175
+ describe('🚫 토큰 추출 실패 테스트', () => {
176
+ it('Authorization 헤더가 없을 때 UnauthorizedException을 던져야 함', async () => {
177
+ // 준비
178
+ const context = createMockExecutionContext({}); // 헤더 없음
179
+
180
+ reflector.get
181
+ .mockReturnValueOnce(undefined)
182
+ .mockReturnValueOnce(['p3-CISO-0']);
183
+
184
+ // 실행 & 검증
185
+ await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
186
+ await expect(guard.canActivate(context)).rejects.toThrow('Authorization header is missing');
187
+ });
188
+
189
+ it('Authorization 헤더가 Bearer가 아닐 때 UnauthorizedException을 던져야 함', async () => {
190
+ // 준비
191
+ const context = createMockExecutionContext({
192
+ authorization: 'Basic sometoken',
193
+ });
194
+
195
+ reflector.get
196
+ .mockReturnValueOnce(undefined)
197
+ .mockReturnValueOnce(['p3-CISO-0']);
198
+
199
+ // 실행 & 검증
200
+ await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
201
+ await expect(guard.canActivate(context)).rejects.toThrow('Authorization token type not supported');
202
+ });
203
+
204
+ it('Bearer 헤더에서 토큰을 올바르게 추출해야 함', async () => {
205
+ // 준비
206
+ const context = createMockExecutionContext({
207
+ authorization: `Bearer ${testToken}`,
208
+ });
209
+
210
+ reflector.get
211
+ .mockReturnValueOnce(undefined)
212
+ .mockReturnValueOnce(['p3-CISO-0']);
213
+ tokenUtil.verifyToken.mockResolvedValueOnce(mockPayload);
214
+
215
+ // 실행
216
+ await guard.canActivate(context);
217
+
218
+ // 검증 - 토큰이 올바르게 추출되어 verifyToken에 전달됨
219
+ expect(tokenUtil.verifyToken).toHaveBeenCalledWith(
220
+ testToken,
221
+ undefined,
222
+ ['p3-CISO-0']
223
+ );
224
+ });
225
+ });
226
+
227
+ describe('❌ 토큰 검증 실패 테스트', () => {
228
+ it('토큰 검증에서 UnauthorizedException이 발생하면 다시 던져야 함', async () => {
229
+ // 준비
230
+ const context = createMockExecutionContext({
231
+ authorization: `Bearer ${testToken}`,
232
+ });
233
+
234
+ reflector.get
235
+ .mockReturnValueOnce(undefined)
236
+ .mockReturnValueOnce(['p3-CISO-0']);
237
+
238
+ const authError = new UnauthorizedException('Invalid token');
239
+ tokenUtil.verifyToken.mockRejectedValueOnce(authError);
240
+
241
+ // 실행 & 검증
242
+ await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
243
+ });
244
+
245
+ it('다른 에러가 발생하면 일반적인 에러 메시지를 던져야 함', async () => {
246
+ // 준비
247
+ const context = createMockExecutionContext({
248
+ authorization: `Bearer ${testToken}`,
249
+ });
250
+
251
+ reflector.get
252
+ .mockReturnValueOnce(undefined)
253
+ .mockReturnValueOnce(['p3-CISO-0']);
254
+
255
+ tokenUtil.verifyToken.mockRejectedValueOnce(new Error('Some other error'));
256
+
257
+ // 실행 & 검증
258
+ await expect(guard.canActivate(context)).rejects.toThrow('요청을 처리하지 못하였습니다.');
259
+ });
260
+ });
261
+
262
+ describe('🔍 실제 JWT 토큰 분석', () => {
263
+ it('제공된 JWT 토큰의 페이로드를 올바르게 디코딩해야 함', () => {
264
+ // JWT 토큰을 수동으로 디코딩하여 모의 데이터와 비교
265
+ const [header, payload, signature] = testToken.split('.');
266
+ const decodedPayload = JSON.parse(Buffer.from(payload, 'base64url').toString());
267
+
268
+ console.log('🔍 디코딩된 토큰 페이로드:');
269
+ console.log(JSON.stringify(decodedPayload, null, 2));
270
+
271
+ // 모의 페이로드가 실제 토큰과 일치하는지 확인
272
+ expect(decodedPayload.userRoles).toEqual(['p3-CISO-0']);
273
+ expect(decodedPayload.managerId).toBe('manager-0196445c-8ec7-7078-a142-4e7db9a4aaea');
274
+ expect(decodedPayload.clientId).toBe('point3-01963c85-446e-74c9-af79-48e25465c327');
275
+ expect(decodedPayload.sub).toBe('yuliuftso1d0');
276
+ expect(decodedPayload.iss).toBe('https://logto.point3.io/oidc');
277
+
278
+ // 토큰 만료 시간 확인 (Unix timestamp)
279
+ const expirationDate = new Date(decodedPayload.exp * 1000);
280
+ const issuedDate = new Date(decodedPayload.iat * 1000);
281
+
282
+ console.log(`📅 토큰 발급 시간: ${issuedDate.toISOString()}`);
283
+ console.log(`⏰ 토큰 만료 시간: ${expirationDate.toISOString()}`);
284
+ console.log(`🏢 발급자: ${decodedPayload.iss}`);
285
+ console.log(`👤 사용자 역할: ${decodedPayload.userRoles.join(', ')}`);
286
+ });
287
+
288
+ it('토큰에서 추출된 GUID 값들이 올바른 형식인지 확인해야 함', () => {
289
+ const [header, payload, signature] = testToken.split('.');
290
+ const decodedPayload = JSON.parse(Buffer.from(payload, 'base64url').toString());
291
+
292
+ // managerId GUID 검증
293
+ const managerId = p3Values.Guid.parse(decodedPayload.managerId);
294
+ expect(managerId.Prefix == 'manager');
295
+
296
+ // clientId GUID 검증
297
+ const clientId = p3Values.Guid.parse(decodedPayload.clientId);
298
+ expect(clientId.Prefix == 'point3');
299
+
300
+ console.log('✅ GUID 형식 검증 완료:');
301
+ console.log(` Manager ID: ${managerId.toString()}`);
302
+ console.log(` Client ID: ${clientId.toString()}`);
303
+ });
304
+ });
305
+ });
@@ -0,0 +1,76 @@
1
+ import {
2
+ Injectable,
3
+ CanActivate,
4
+ ExecutionContext,
5
+ UnauthorizedException,
6
+ InternalServerErrorException,
7
+ Inject,
8
+ Global,
9
+ HttpStatus
10
+ } from '@nestjs/common';
11
+ import { Reflector } from '@nestjs/core';
12
+ import { IncomingHttpHeaders } from 'http';
13
+
14
+ import * as jose from 'jose';
15
+
16
+ import { p3Values } from 'point3-common-tool';
17
+ import * as token from '../token';
18
+
19
+ @Global()
20
+ @Injectable()
21
+ export class LogtoTokenGuard implements CanActivate {
22
+ constructor(
23
+ private reflector: Reflector,
24
+
25
+ @Inject(token.LogtoTokenVerifierToken)
26
+ private tokenVerifier: token.LogtoTokenVerifier
27
+ ) { }
28
+
29
+ async canActivate(context: ExecutionContext): Promise<boolean> {
30
+ //매타데이터에서 필요한 스코프와 역할을 가져온다.
31
+ const requiredScopes = this.reflector.get<string[]>('requiredScopes', context.getHandler());
32
+ const requiredRoles = this.reflector.get<string[]>('requiredRoles', context.getHandler());
33
+
34
+ const request = context.switchToHttp().getRequest();
35
+
36
+ //헤더에서 베어러 토큰을 추출한다
37
+ try {
38
+ const bearerToken = this.extractBearerTokenFrom(request.headers);
39
+ const result = await this.tokenVerifier.verifyToken(bearerToken, requiredScopes, requiredRoles);
40
+
41
+ // request.user에 사용자 정보를 추가한다.
42
+ request.user = {
43
+ userId: result.sub,
44
+ managerId: p3Values.Guid.parse(result.managerId),
45
+ clientId: p3Values.Guid.parse(result.clientId),
46
+ }
47
+ return true;
48
+ } catch (error) {
49
+ if (error instanceof UnauthorizedException) throw error;
50
+ if (error instanceof jose.errors.JOSEError) throw new UnauthorizedException(error);
51
+ if (error instanceof Error) throw new InternalServerErrorException("요청을 처리하지 못하였습니다.", `${HttpStatus.INTERNAL_SERVER_ERROR}`);
52
+
53
+ throw new UnauthorizedException("접근이 허용되지 않습니다.");
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Extracts the Bearer token from the authorization header.
59
+ * @param headers - The incoming HTTP headers.
60
+ * @returns The extracted token.
61
+ * @throws UnauthorizedException if the authorization header is missing or invalid.
62
+ */
63
+ private extractBearerTokenFrom(headers: IncomingHttpHeaders): string {
64
+ const bearerTokenIdentifier = 'Bearer';
65
+
66
+ if (!headers.authorization) {
67
+ throw new UnauthorizedException('Authorization header is missing');
68
+ }
69
+
70
+ if (!headers.authorization.startsWith(bearerTokenIdentifier)) {
71
+ throw new UnauthorizedException('Authorization token type not supported');
72
+ }
73
+
74
+ return headers.authorization.slice(bearerTokenIdentifier.length + 1);
75
+ };
76
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./guard";
2
+ export * from "./decorator";
@@ -0,0 +1,48 @@
1
+
2
+ export class AccessToken {
3
+ static THRESHOLD_TIME = 1000 * 60 * 10; // 10분
4
+
5
+ accountId: string;
6
+ token: string;
7
+ expiresAt: Date;
8
+
9
+ constructor(accountId: string, token: string, expireInSeconds: number) {
10
+ this.accountId = accountId;
11
+ this.token = token;
12
+ this.expiresAt = new Date(
13
+ Date.now() + expireInSeconds * 1000 - AccessToken.THRESHOLD_TIME,
14
+ );
15
+ }
16
+
17
+ public isExpired(): boolean {
18
+ return this.expiresAt < new Date();
19
+ }
20
+
21
+ public toString(): string {
22
+ return `AccessToken{accountId: ${this.accountId}, expiresAt: ${this.expiresAt}}`;
23
+ }
24
+ }
25
+
26
+ export type AccessTokenPayload = {
27
+ jti: string;
28
+ sub: string;
29
+ iat: number;
30
+ exp: number;
31
+ scope: string;
32
+ client_id: string;
33
+ iss: string;
34
+ aud: string;
35
+ userRoles: string[];
36
+ clientId: string; // 이게 진짜임 ;;
37
+ managerId: string;
38
+ userScopes?: string[];
39
+ };
40
+
41
+ export type IdTokenPayload = {
42
+ sub: string;
43
+ email: string;
44
+ email_verified: boolean;
45
+ name: string;
46
+ phone_number: string;
47
+ username: string;
48
+ };
package/token/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./access-token";
2
+ export * from "./verifier";
@@ -0,0 +1,101 @@
1
+ import { Global, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
2
+ import { ConfigService } from "@nestjs/config";
3
+ import { jwtVerify, createRemoteJWKSet } from "jose";
4
+
5
+ import * as token from "./access-token";
6
+
7
+ export const LogtoTokenVerifierToken = Symbol.for("LogtoTokenVerifier");
8
+
9
+ @Global()
10
+ @Injectable()
11
+ export class LogtoTokenVerifier {
12
+ constructor(
13
+ @Inject(ConfigService)
14
+ private readonly configService: ConfigService
15
+ ) { }
16
+
17
+ /**
18
+ * 토큰을 검증하고 필요에 따라 필수 스코프와 역할을 확인합니다.
19
+ * @param token 검증할 토큰입니다.
20
+ * @param requiredScopes 선택적으로 확인할 스코프입니다.
21
+ * @param requiredRoles 선택적으로 확인할 역할입니다.
22
+ * @returns 토큰이 유효한 경우 토큰 페이로드를 반환하는 Promise입니다.
23
+ * @throws UnauthorizedException 토큰이 유효하지 않은 경우 발생합니다.
24
+ */
25
+ public async verifyToken(token: string): Promise<token.AccessTokenPayload>;
26
+ public async verifyToken(token: string, requiredScopes: string[], requiredRoles: string[]): Promise<token.AccessTokenPayload>;
27
+ public async verifyToken(token: string, requiredScopes?: string[], requiredRoles?: string[]): Promise<token.AccessTokenPayload> {
28
+ if (!token) throw new UnauthorizedException('엑세스 토큰이 존재하지 않습니다.');
29
+
30
+ const jwksUri = this.configService.get<string>("LOGTO_JWKS_URI") ?? 'http://localhost:3001/oidc/jwks';
31
+ const issuer = this.configService.get<string>("LOGTO_AUTH_ISSUER");
32
+
33
+ const { payload } = await jwtVerify(
34
+ token, createRemoteJWKSet(new URL(jwksUri)),
35
+ { issuer }
36
+ );
37
+
38
+ const tokenPayload = payload as token.AccessTokenPayload;
39
+
40
+ if (requiredScopes || requiredRoles) {
41
+ this.shouldContainRequiredPrivileges(
42
+ tokenPayload, requiredScopes, requiredRoles);
43
+ }
44
+
45
+ return tokenPayload;
46
+ }
47
+
48
+ /**
49
+ * id token을 검증합니다.
50
+ * @param token id token 문자열입니다.
51
+ * @returns id token 페이로드입니다.
52
+ */
53
+ public async verifyIdToken(token: string): Promise<token.IdTokenPayload> {
54
+ const jwksUri = process.env.LOGTO_JWKS_URI ?? 'http://localhost:3001/oidc/jwks';
55
+ const issuer = process.env.LOGTO_AUTH_ISSUER;
56
+
57
+ const { payload } = await jwtVerify(
58
+ token,
59
+ createRemoteJWKSet(new URL(jwksUri)),
60
+ { issuer }
61
+ );
62
+ return payload as token.IdTokenPayload;
63
+ }
64
+
65
+ /**
66
+ * 토큰 페이로드를 통해 필요한 스코프와 역할 등 추가적인 검사를 수행합니다.
67
+ * @param payload 토큰 페이로드입니다.
68
+ * @param requiredScopes 선택적으로 확인할 스코프입니다.
69
+ * @param requiredRoles 선택적으로 확인할 역할입니다.
70
+ */
71
+ private shouldContainRequiredPrivileges(
72
+ payload: token.AccessTokenPayload,
73
+ requiredScopes?: string[],
74
+ requiredRoles?: string[]
75
+ ): void {
76
+ const { userScopes, userRoles } = payload;
77
+ const scopes = userScopes?.flat() ?? [];
78
+
79
+ if (this.hasInsufficientScopes(requiredScopes, scopes)) {
80
+ throw new UnauthorizedException(
81
+ { code: 'auth.insufficient_scope', status: 403 },
82
+ { cause: requiredScopes }
83
+ );
84
+ }
85
+
86
+ if (this.hasInsufficientRoles(requiredRoles, userRoles)) {
87
+ throw new UnauthorizedException(
88
+ { code: 'auth.role_mismatch', status: 403 },
89
+ { cause: requiredRoles }
90
+ );
91
+ }
92
+ }
93
+
94
+ private hasInsufficientScopes(requiredScopes: string[] | undefined, userScopes: string[]): boolean {
95
+ return !!(requiredScopes && requiredScopes.length > 0 && !requiredScopes.every(scope => userScopes.includes(scope)));
96
+ }
97
+
98
+ private hasInsufficientRoles(requiredRoles: string[] | undefined, userRoles: string[]): boolean {
99
+ return !!(requiredRoles && requiredRoles.length > 0 && !requiredRoles.some(role => userRoles.includes(role)));
100
+ }
101
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "declaration": true,
5
+ "removeComments": true,
6
+ "emitDecoratorMetadata": true,
7
+ "experimentalDecorators": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "target": "ES2021",
10
+ "sourceMap": true,
11
+ "outDir": "./dist",
12
+ "baseUrl": "./",
13
+ "incremental": true,
14
+ "skipLibCheck": true,
15
+ "strictNullChecks": false,
16
+ "noImplicitAny": false,
17
+ "strictBindCallApply": false,
18
+ "forceConsistentCasingInFileNames": false,
19
+ "noFallthroughCasesInSwitch": false,
20
+ "moduleResolution": "node",
21
+ "esModuleInterop": true,
22
+ },
23
+ }