@opensaas/stack-auth 0.1.2 → 0.1.4

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.
@@ -0,0 +1,352 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ createBetterAuthMcpAdapter,
4
+ withMcpAuth,
5
+ mcpSessionToContextSession,
6
+ hasScopes,
7
+ isSessionExpired,
8
+ createOAuthDiscoveryHandler,
9
+ createOAuthProtectedResourceHandler,
10
+ } from '../src/mcp/better-auth.js'
11
+ import type { BetterAuthInstance } from '../src/mcp/better-auth.js'
12
+ import type { McpSession } from '@opensaas/stack-core/mcp'
13
+
14
+ describe('Better Auth MCP Adapter', () => {
15
+ describe('createBetterAuthMcpAdapter', () => {
16
+ it('should create a session provider from Better Auth instance', async () => {
17
+ const mockSession: McpSession = {
18
+ userId: 'user-123',
19
+ scopes: ['read', 'write'],
20
+ }
21
+
22
+ const mockAuth: BetterAuthInstance = {
23
+ api: {
24
+ getMcpSession: vi.fn(async () => mockSession),
25
+ },
26
+ }
27
+
28
+ const adapter = createBetterAuthMcpAdapter(mockAuth)
29
+ expect(typeof adapter).toBe('function')
30
+
31
+ const headers = new Headers({ Authorization: 'Bearer token123' })
32
+ const session = await adapter(headers)
33
+
34
+ expect(session).toEqual(mockSession)
35
+ expect(mockAuth.api.getMcpSession).toHaveBeenCalledWith({ headers })
36
+ })
37
+
38
+ it('should return null when no session exists', async () => {
39
+ const mockAuth: BetterAuthInstance = {
40
+ api: {
41
+ getMcpSession: vi.fn(async () => null),
42
+ },
43
+ }
44
+
45
+ const adapter = createBetterAuthMcpAdapter(mockAuth)
46
+ const headers = new Headers()
47
+ const session = await adapter(headers)
48
+
49
+ expect(session).toBeNull()
50
+ })
51
+ })
52
+
53
+ describe('withMcpAuth', () => {
54
+ it('should wrap handler with authentication', async () => {
55
+ const mockSession: McpSession = {
56
+ userId: 'user-123',
57
+ scopes: ['read'],
58
+ }
59
+
60
+ const mockAuth: BetterAuthInstance = {
61
+ api: {
62
+ getMcpSession: vi.fn(async () => mockSession),
63
+ },
64
+ }
65
+
66
+ const mockHandler = vi.fn(async () => new Response('OK'))
67
+ const wrappedHandler = withMcpAuth(mockAuth, mockHandler)
68
+
69
+ const request = new Request('http://localhost/api', {
70
+ headers: { Authorization: 'Bearer token123' },
71
+ })
72
+
73
+ const response = await wrappedHandler(request)
74
+
75
+ expect(mockHandler).toHaveBeenCalledWith(request, mockSession)
76
+ expect(response.status).not.toBe(401)
77
+ })
78
+
79
+ it('should return 401 when no session exists', async () => {
80
+ const mockAuth: BetterAuthInstance = {
81
+ api: {
82
+ getMcpSession: vi.fn(async () => null),
83
+ },
84
+ }
85
+
86
+ const mockHandler = vi.fn(async () => new Response('OK'))
87
+ const wrappedHandler = withMcpAuth(mockAuth, mockHandler)
88
+
89
+ const request = new Request('http://localhost/api')
90
+ const response = await wrappedHandler(request)
91
+
92
+ expect(response.status).toBe(401)
93
+ expect(response.headers.get('WWW-Authenticate')).toContain('Bearer')
94
+ expect(mockHandler).not.toHaveBeenCalled()
95
+ })
96
+
97
+ it('should pass through handler response', async () => {
98
+ const mockSession: McpSession = {
99
+ userId: 'user-123',
100
+ scopes: [],
101
+ }
102
+
103
+ const mockAuth: BetterAuthInstance = {
104
+ api: {
105
+ getMcpSession: vi.fn(async () => mockSession),
106
+ },
107
+ }
108
+
109
+ const mockResponse = new Response('Custom Response', { status: 200 })
110
+ const mockHandler = vi.fn(async () => mockResponse)
111
+ const wrappedHandler = withMcpAuth(mockAuth, mockHandler)
112
+
113
+ const request = new Request('http://localhost/api', {
114
+ headers: { Authorization: 'Bearer token123' },
115
+ })
116
+
117
+ const response = await wrappedHandler(request)
118
+
119
+ expect(response).toBe(mockResponse)
120
+ expect(await response.text()).toBe('Custom Response')
121
+ })
122
+ })
123
+
124
+ describe('mcpSessionToContextSession', () => {
125
+ it('should extract userId from MCP session', () => {
126
+ const mcpSession: McpSession = {
127
+ userId: 'user-456',
128
+ scopes: ['admin'],
129
+ accessToken: 'token-abc',
130
+ }
131
+
132
+ const contextSession = mcpSessionToContextSession(mcpSession)
133
+
134
+ expect(contextSession).toEqual({ userId: 'user-456' })
135
+ })
136
+
137
+ it('should work with minimal session', () => {
138
+ const mcpSession: McpSession = {
139
+ userId: 'user-789',
140
+ }
141
+
142
+ const contextSession = mcpSessionToContextSession(mcpSession)
143
+
144
+ expect(contextSession).toEqual({ userId: 'user-789' })
145
+ })
146
+ })
147
+
148
+ describe('hasScopes', () => {
149
+ it('should return true when all required scopes are present', () => {
150
+ const session: McpSession = {
151
+ userId: 'user-123',
152
+ scopes: ['read', 'write', 'delete'],
153
+ }
154
+
155
+ expect(hasScopes(session, ['read'])).toBe(true)
156
+ expect(hasScopes(session, ['read', 'write'])).toBe(true)
157
+ expect(hasScopes(session, ['write', 'delete'])).toBe(true)
158
+ })
159
+
160
+ it('should return false when some required scopes are missing', () => {
161
+ const session: McpSession = {
162
+ userId: 'user-123',
163
+ scopes: ['read'],
164
+ }
165
+
166
+ expect(hasScopes(session, ['write'])).toBe(false)
167
+ expect(hasScopes(session, ['read', 'write'])).toBe(false)
168
+ })
169
+
170
+ it('should return false when session has no scopes', () => {
171
+ const session: McpSession = {
172
+ userId: 'user-123',
173
+ }
174
+
175
+ expect(hasScopes(session, ['read'])).toBe(false)
176
+ })
177
+
178
+ it('should return false when scopes array is empty', () => {
179
+ const session: McpSession = {
180
+ userId: 'user-123',
181
+ scopes: [],
182
+ }
183
+
184
+ expect(hasScopes(session, ['read'])).toBe(false)
185
+ })
186
+
187
+ it('should return true when no scopes are required', () => {
188
+ const session: McpSession = {
189
+ userId: 'user-123',
190
+ scopes: ['read'],
191
+ }
192
+
193
+ expect(hasScopes(session, [])).toBe(true)
194
+ })
195
+ })
196
+
197
+ describe('isSessionExpired', () => {
198
+ it('should return true when session is expired', () => {
199
+ const pastDate = new Date()
200
+ pastDate.setHours(pastDate.getHours() - 1) // 1 hour ago
201
+
202
+ const session: McpSession = {
203
+ userId: 'user-123',
204
+ expiresAt: pastDate,
205
+ }
206
+
207
+ expect(isSessionExpired(session)).toBe(true)
208
+ })
209
+
210
+ it('should return false when session is not expired', () => {
211
+ const futureDate = new Date()
212
+ futureDate.setHours(futureDate.getHours() + 1) // 1 hour from now
213
+
214
+ const session: McpSession = {
215
+ userId: 'user-123',
216
+ expiresAt: futureDate,
217
+ }
218
+
219
+ expect(isSessionExpired(session)).toBe(false)
220
+ })
221
+
222
+ it('should return false when no expiresAt is set', () => {
223
+ const session: McpSession = {
224
+ userId: 'user-123',
225
+ }
226
+
227
+ expect(isSessionExpired(session)).toBe(false)
228
+ })
229
+ })
230
+
231
+ describe('createOAuthDiscoveryHandler', () => {
232
+ it('should create a handler that proxies to Better Auth', async () => {
233
+ const mockAuth: BetterAuthInstance = {
234
+ api: {},
235
+ }
236
+
237
+ const handler = createOAuthDiscoveryHandler(mockAuth)
238
+
239
+ // Mock fetch to capture the proxy call
240
+ const originalFetch = global.fetch
241
+ const mockFetch = vi.fn(async () => new Response('{"issuer":"http://localhost"}'))
242
+ global.fetch = mockFetch as typeof fetch
243
+
244
+ const request = new Request('http://localhost/.well-known/oauth-authorization-server')
245
+ await handler(request)
246
+
247
+ expect(mockFetch).toHaveBeenCalledWith(
248
+ 'http://localhost/api/auth/.well-known/oauth-authorization-server',
249
+ expect.any(Object),
250
+ )
251
+
252
+ // Restore original fetch
253
+ global.fetch = originalFetch
254
+ })
255
+ })
256
+
257
+ describe('createOAuthProtectedResourceHandler', () => {
258
+ it('should create a handler that proxies to Better Auth', async () => {
259
+ const mockAuth: BetterAuthInstance = {
260
+ api: {},
261
+ }
262
+
263
+ const handler = createOAuthProtectedResourceHandler(mockAuth)
264
+
265
+ // Mock fetch to capture the proxy call
266
+ const originalFetch = global.fetch
267
+ const mockFetch = vi.fn(async () => new Response('{"resource":"http://localhost"}'))
268
+ global.fetch = mockFetch as typeof fetch
269
+
270
+ const request = new Request('http://localhost/.well-known/oauth-protected-resource')
271
+ await handler(request)
272
+
273
+ expect(mockFetch).toHaveBeenCalledWith(
274
+ 'http://localhost/api/auth/.well-known/oauth-protected-resource',
275
+ expect.any(Object),
276
+ )
277
+
278
+ // Restore original fetch
279
+ global.fetch = originalFetch
280
+ })
281
+ })
282
+
283
+ describe('Integration scenarios', () => {
284
+ it('should support full auth flow with scopes and expiration', async () => {
285
+ const futureDate = new Date()
286
+ futureDate.setHours(futureDate.getHours() + 1)
287
+
288
+ const mockSession: McpSession = {
289
+ userId: 'user-123',
290
+ scopes: ['read:posts', 'write:posts', 'admin'],
291
+ accessToken: 'token-abc-123',
292
+ expiresAt: futureDate,
293
+ }
294
+
295
+ const mockAuth: BetterAuthInstance = {
296
+ api: {
297
+ getMcpSession: vi.fn(async () => mockSession),
298
+ },
299
+ }
300
+
301
+ const adapter = createBetterAuthMcpAdapter(mockAuth)
302
+ const headers = new Headers({ Authorization: 'Bearer token-abc-123' })
303
+ const session = await adapter(headers)
304
+
305
+ // Verify session was retrieved
306
+ expect(session).toBeTruthy()
307
+ expect(session?.userId).toBe('user-123')
308
+
309
+ // Verify scopes
310
+ expect(hasScopes(session!, ['read:posts'])).toBe(true)
311
+ expect(hasScopes(session!, ['read:posts', 'write:posts'])).toBe(true)
312
+ expect(hasScopes(session!, ['read:posts', 'write:posts', 'delete:posts'])).toBe(false)
313
+
314
+ // Verify expiration
315
+ expect(isSessionExpired(session!)).toBe(false)
316
+
317
+ // Verify context session conversion
318
+ const contextSession = mcpSessionToContextSession(session!)
319
+ expect(contextSession.userId).toBe('user-123')
320
+ })
321
+
322
+ it('should handle expired session with valid scopes', async () => {
323
+ const pastDate = new Date()
324
+ pastDate.setHours(pastDate.getHours() - 1)
325
+
326
+ const mockSession: McpSession = {
327
+ userId: 'user-123',
328
+ scopes: ['read', 'write'],
329
+ expiresAt: pastDate,
330
+ }
331
+
332
+ const mockAuth: BetterAuthInstance = {
333
+ api: {
334
+ getMcpSession: vi.fn(async () => mockSession),
335
+ },
336
+ }
337
+
338
+ const adapter = createBetterAuthMcpAdapter(mockAuth)
339
+ const headers = new Headers({ Authorization: 'Bearer expired-token' })
340
+ const session = await adapter(headers)
341
+
342
+ // Session is returned (Better Auth handles validity)
343
+ expect(session).toBeTruthy()
344
+
345
+ // But application can check expiration
346
+ expect(isSessionExpired(session!)).toBe(true)
347
+
348
+ // Scopes are still valid
349
+ expect(hasScopes(session!, ['read'])).toBe(true)
350
+ })
351
+ })
352
+ })