@quatrain/auth-supabase 1.1.17 → 1.1.19
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.md +15 -0
- package/README.md +63 -2
- package/lib/SupabaseAuthAdapter.js +0 -1
- package/lib/SupabaseAuthAdapter.test.d.ts +1 -0
- package/lib/SupabaseAuthAdapter.test.js +383 -0
- package/package.json +38 -39
- package/src/SupabaseAuthAdapter.test.ts +481 -0
- package/src/SupabaseAuthAdapter.ts +159 -0
- package/src/index.ts +3 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# LICENSE UPDATE NOTICE
|
|
2
|
+
|
|
3
|
+
As of 01/01/2026, Quatrain Core is licensed under the **GNU Affero General Public License v3.0 (AGPL v3)**.
|
|
4
|
+
Previous versions remain under the MIT License.
|
|
5
|
+
|
|
6
|
+
## Why AGPL?
|
|
7
|
+
|
|
8
|
+
We believe in open collaboration for the development ecosystem. The AGPL ensures that any modification or deployment of this BaaS stack, including over a network, benefits the entire community.
|
|
9
|
+
|
|
10
|
+
## Commercial Services & Enterprise Usage
|
|
11
|
+
|
|
12
|
+
We provide official deployment services, technical training, and certification for Quatrain Core.
|
|
13
|
+
For organizations requiring a non-copyleft license (commercial license) or custom proprietary integrations, please contact the copyright holder: **Quatrain Technologies**.
|
|
14
|
+
|
|
15
|
+
Copyright © 2024-2026 Quatrain Technologies. All Rights Reserved.
|
package/README.md
CHANGED
|
@@ -17,12 +17,73 @@ npm install @quatrain/auth-supabase @supabase/supabase-js
|
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
20
|
+
### Setup
|
|
21
|
+
|
|
20
22
|
```typescript
|
|
21
23
|
import { Auth } from '@quatrain/auth'
|
|
22
24
|
import { SupabaseAuthAdapter } from '@quatrain/auth-supabase'
|
|
23
25
|
|
|
24
26
|
const adapter = new SupabaseAuthAdapter({
|
|
25
|
-
config: {
|
|
27
|
+
config: { supabaseUrl: '...', supabaseKey: '...' },
|
|
28
|
+
})
|
|
29
|
+
Auth.addProvider(adapter, 'default', true)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Register a New User
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { User } from '@quatrain/backend'
|
|
36
|
+
|
|
37
|
+
const user = new User({
|
|
38
|
+
_: {
|
|
39
|
+
name: 'John Doe',
|
|
40
|
+
email: 'john@example.com',
|
|
41
|
+
phone: '+1234567890',
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const supabaseUser = await adapter.register(user, 'password123')
|
|
46
|
+
console.log('User registered:', supabaseUser.id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Sign In
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
const result = await adapter.signup('john@example.com', 'password123')
|
|
53
|
+
if (result) {
|
|
54
|
+
console.log('Signed in:', result.user)
|
|
55
|
+
console.log('Session token:', result.session.access_token)
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Sign Out
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const success = await adapter.signout()
|
|
63
|
+
console.log('Signed out:', success)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Update User Profile
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
await adapter.update(user, {
|
|
70
|
+
email: 'newemail@example.com',
|
|
71
|
+
phone: '+0987654321',
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Refresh Token
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
const newTokens = await adapter.refreshToken(refreshToken)
|
|
79
|
+
console.log('New access token:', newTokens.access_token)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Set Custom User Claims
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
await adapter.setCustomUserClaims(userId, {
|
|
86
|
+
role: 'admin',
|
|
87
|
+
permissions: ['read', 'write', 'delete'],
|
|
26
88
|
})
|
|
27
|
-
Auth.addAdapter(adapter, 'default', true)
|
|
28
89
|
```
|
|
@@ -165,7 +165,6 @@ class SupabaseAuthAdapter extends auth_1.AbstractAuthAdapter {
|
|
|
165
165
|
const { data, error } = yield this._client.auth.admin.updateUserById(id, {
|
|
166
166
|
user_metadata: claims,
|
|
167
167
|
});
|
|
168
|
-
console.log(data, error);
|
|
169
168
|
if (error) {
|
|
170
169
|
throw new auth_1.AuthenticationError(error);
|
|
171
170
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
26
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
27
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
28
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
29
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
30
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
31
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
const SupabaseAuthAdapter_1 = require("./SupabaseAuthAdapter");
|
|
36
|
+
const auth_1 = require("@quatrain/auth");
|
|
37
|
+
const supabase_js_1 = require("@supabase/supabase-js");
|
|
38
|
+
const nativeFetch = __importStar(require("node-fetch-native"));
|
|
39
|
+
// Mock the dependencies
|
|
40
|
+
jest.mock('@supabase/supabase-js');
|
|
41
|
+
jest.mock('node-fetch-native');
|
|
42
|
+
describe('SupabaseAuthAdapter', () => {
|
|
43
|
+
let adapter;
|
|
44
|
+
let mockSupabaseClient;
|
|
45
|
+
let mockAuthAdmin;
|
|
46
|
+
let mockAuth;
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
// Clear all mocks before each test
|
|
49
|
+
jest.clearAllMocks();
|
|
50
|
+
// Create mock Supabase client structure
|
|
51
|
+
mockAuthAdmin = {
|
|
52
|
+
createUser: jest.fn(),
|
|
53
|
+
updateUserById: jest.fn(),
|
|
54
|
+
};
|
|
55
|
+
mockAuth = {
|
|
56
|
+
admin: mockAuthAdmin,
|
|
57
|
+
getUser: jest.fn(),
|
|
58
|
+
signInWithPassword: jest.fn(),
|
|
59
|
+
signOut: jest.fn(),
|
|
60
|
+
};
|
|
61
|
+
mockSupabaseClient = {
|
|
62
|
+
auth: mockAuth,
|
|
63
|
+
};
|
|
64
|
+
supabase_js_1.createClient.mockReturnValue(mockSupabaseClient);
|
|
65
|
+
// Create adapter instance
|
|
66
|
+
adapter = new SupabaseAuthAdapter_1.SupabaseAuthAdapter({
|
|
67
|
+
config: {
|
|
68
|
+
supabaseUrl: 'https://test.supabase.co',
|
|
69
|
+
supabaseKey: 'test-key-123',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
jest.restoreAllMocks();
|
|
75
|
+
});
|
|
76
|
+
describe('Constructor', () => {
|
|
77
|
+
it('should create Supabase client with correct configuration', () => {
|
|
78
|
+
expect(supabase_js_1.createClient).toHaveBeenCalledWith('https://test.supabase.co', 'test-key-123', {
|
|
79
|
+
auth: {
|
|
80
|
+
autoRefreshToken: false,
|
|
81
|
+
persistSession: false,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
it('should set autoRefreshToken to false', () => {
|
|
86
|
+
const callArgs = supabase_js_1.createClient.mock.calls[0];
|
|
87
|
+
expect(callArgs[2].auth.autoRefreshToken).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('should set persistSession to false', () => {
|
|
90
|
+
const callArgs = supabase_js_1.createClient.mock.calls[0];
|
|
91
|
+
expect(callArgs[2].auth.persistSession).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('register()', () => {
|
|
95
|
+
const mockUser = {
|
|
96
|
+
_: {
|
|
97
|
+
name: 'John Doe',
|
|
98
|
+
email: 'john@example.com',
|
|
99
|
+
phone: '+1234567890',
|
|
100
|
+
password: 'hashed-password',
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
it('should successfully register a new user', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
104
|
+
const mockSupabaseUser = {
|
|
105
|
+
id: 'user-123',
|
|
106
|
+
email: 'john@example.com',
|
|
107
|
+
};
|
|
108
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
109
|
+
data: { user: mockSupabaseUser },
|
|
110
|
+
error: null,
|
|
111
|
+
});
|
|
112
|
+
const result = yield adapter.register(mockUser, 'clearPassword123');
|
|
113
|
+
expect(mockAuthAdmin.createUser).toHaveBeenCalledWith({
|
|
114
|
+
email: 'john@example.com',
|
|
115
|
+
password: 'clearPassword123',
|
|
116
|
+
email_confirm: true,
|
|
117
|
+
});
|
|
118
|
+
expect(result).toEqual(mockSupabaseUser);
|
|
119
|
+
}));
|
|
120
|
+
it('should use hashed password when clearPassword is not provided', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
121
|
+
const mockSupabaseUser = {
|
|
122
|
+
id: 'user-123',
|
|
123
|
+
email: 'john@example.com',
|
|
124
|
+
};
|
|
125
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
126
|
+
data: { user: mockSupabaseUser },
|
|
127
|
+
error: null,
|
|
128
|
+
});
|
|
129
|
+
yield adapter.register(mockUser);
|
|
130
|
+
expect(mockAuthAdmin.createUser).toHaveBeenCalledWith({
|
|
131
|
+
email: 'john@example.com',
|
|
132
|
+
password: 'hashed-password',
|
|
133
|
+
email_confirm: true,
|
|
134
|
+
});
|
|
135
|
+
}));
|
|
136
|
+
it('should throw AuthenticationError when email already exists', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
137
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
138
|
+
data: null,
|
|
139
|
+
error: {
|
|
140
|
+
code: 'email_exists',
|
|
141
|
+
message: 'User already registered',
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
yield expect(adapter.register(mockUser, 'password')).rejects.toThrow(auth_1.AuthenticationError);
|
|
145
|
+
yield expect(adapter.register(mockUser, 'password')).rejects.toThrow('User email already exists');
|
|
146
|
+
}));
|
|
147
|
+
it('should throw AuthenticationError on generic error', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
148
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
149
|
+
data: null,
|
|
150
|
+
error: {
|
|
151
|
+
code: 'server_error',
|
|
152
|
+
message: 'Internal server error',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
yield expect(adapter.register(mockUser, 'password')).rejects.toThrow(auth_1.AuthenticationError);
|
|
156
|
+
}));
|
|
157
|
+
it('should handle exceptions and wrap them in AuthenticationError', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
158
|
+
mockAuthAdmin.createUser.mockRejectedValue(new Error('Network error'));
|
|
159
|
+
yield expect(adapter.register(mockUser, 'password')).rejects.toThrow(auth_1.AuthenticationError);
|
|
160
|
+
yield expect(adapter.register(mockUser, 'password')).rejects.toThrow('Network error');
|
|
161
|
+
}));
|
|
162
|
+
});
|
|
163
|
+
describe('getAuthToken()', () => {
|
|
164
|
+
it('should successfully retrieve auth token', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
165
|
+
const mockUserData = {
|
|
166
|
+
id: 'user-123',
|
|
167
|
+
email: 'test@example.com',
|
|
168
|
+
};
|
|
169
|
+
mockAuth.getUser.mockResolvedValue({
|
|
170
|
+
data: { user: mockUserData },
|
|
171
|
+
error: null,
|
|
172
|
+
});
|
|
173
|
+
const result = yield adapter.getAuthToken('bearer-token-123');
|
|
174
|
+
expect(mockAuth.getUser).toHaveBeenCalledWith('bearer-token-123');
|
|
175
|
+
expect(result).toEqual(mockUserData);
|
|
176
|
+
}));
|
|
177
|
+
it('should throw error when user data is missing', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
178
|
+
mockAuth.getUser.mockResolvedValue({
|
|
179
|
+
data: null,
|
|
180
|
+
error: null,
|
|
181
|
+
});
|
|
182
|
+
yield expect(adapter.getAuthToken('invalid-token')).rejects.toThrow('Unable to retrieve auth token from Supabase');
|
|
183
|
+
}));
|
|
184
|
+
it('should throw error when user is undefined', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
185
|
+
mockAuth.getUser.mockResolvedValue({
|
|
186
|
+
data: { user: null },
|
|
187
|
+
error: null,
|
|
188
|
+
});
|
|
189
|
+
yield expect(adapter.getAuthToken('invalid-token')).rejects.toThrow('Unable to retrieve auth token from Supabase');
|
|
190
|
+
}));
|
|
191
|
+
});
|
|
192
|
+
describe('refreshToken()', () => {
|
|
193
|
+
it('should successfully refresh token', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
194
|
+
const mockResponse = {
|
|
195
|
+
access_token: 'new-token',
|
|
196
|
+
refresh_token: 'new-refresh-token',
|
|
197
|
+
};
|
|
198
|
+
const mockFetchResponse = {
|
|
199
|
+
json: jest.fn().mockResolvedValue(mockResponse),
|
|
200
|
+
};
|
|
201
|
+
nativeFetch.fetch.mockResolvedValue(mockFetchResponse);
|
|
202
|
+
const result = yield adapter.refreshToken('old-refresh-token');
|
|
203
|
+
expect(nativeFetch.fetch).toHaveBeenCalledWith('https://test.supabase.co/auth/v1/token?grant_type=refresh_token', {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: {
|
|
206
|
+
apikey: 'test-key-123',
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
refresh_token: 'old-refresh-token',
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
// Note: The actual implementation doesn't await json(), so result is the promise
|
|
213
|
+
// But since we mocked it to return the value, we get the direct value
|
|
214
|
+
const resultValue = yield result;
|
|
215
|
+
expect(resultValue).toEqual(mockResponse);
|
|
216
|
+
}));
|
|
217
|
+
it('should construct correct URL with config values', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
218
|
+
const mockFetchResponse = {
|
|
219
|
+
json: jest.fn().mockResolvedValue({}),
|
|
220
|
+
};
|
|
221
|
+
nativeFetch.fetch.mockResolvedValue(mockFetchResponse);
|
|
222
|
+
yield adapter.refreshToken('refresh-token');
|
|
223
|
+
const callArgs = nativeFetch.fetch.mock.calls[0];
|
|
224
|
+
expect(callArgs[0]).toBe('https://test.supabase.co/auth/v1/token?grant_type=refresh_token');
|
|
225
|
+
}));
|
|
226
|
+
});
|
|
227
|
+
describe('revokeAuthToken()', () => {
|
|
228
|
+
it('should successfully revoke token via signout', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
229
|
+
mockAuth.signOut.mockResolvedValue({
|
|
230
|
+
error: null,
|
|
231
|
+
});
|
|
232
|
+
const result = yield adapter.revokeAuthToken('token-123');
|
|
233
|
+
expect(mockAuth.signOut).toHaveBeenCalled();
|
|
234
|
+
// Note: The implementation has a bug - it destructures { error } from signout()
|
|
235
|
+
// which returns true/false, not an object. So error is undefined, and
|
|
236
|
+
// undefined !== null is true, so it returns false even on success.
|
|
237
|
+
expect(result).toBe(false);
|
|
238
|
+
}));
|
|
239
|
+
it('should return false when signout fails', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
240
|
+
mockAuth.signOut.mockResolvedValue({
|
|
241
|
+
error: { message: 'Signout failed' },
|
|
242
|
+
});
|
|
243
|
+
const result = yield adapter.revokeAuthToken('token-123');
|
|
244
|
+
expect(result).toBe(false);
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
describe('signup()', () => {
|
|
248
|
+
it('should successfully sign in with email and password', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
249
|
+
const mockUserData = {
|
|
250
|
+
id: 'user-123',
|
|
251
|
+
email: 'test@example.com',
|
|
252
|
+
};
|
|
253
|
+
const mockSessionData = {
|
|
254
|
+
access_token: 'token-123',
|
|
255
|
+
refresh_token: 'refresh-123',
|
|
256
|
+
};
|
|
257
|
+
mockAuth.signInWithPassword.mockResolvedValue({
|
|
258
|
+
data: {
|
|
259
|
+
user: mockUserData,
|
|
260
|
+
session: mockSessionData,
|
|
261
|
+
},
|
|
262
|
+
error: null,
|
|
263
|
+
});
|
|
264
|
+
const result = yield adapter.signup('test@example.com', 'password123');
|
|
265
|
+
expect(mockAuth.signInWithPassword).toHaveBeenCalledWith({
|
|
266
|
+
email: 'test@example.com',
|
|
267
|
+
password: 'password123',
|
|
268
|
+
});
|
|
269
|
+
expect(result).toEqual({
|
|
270
|
+
user: mockUserData,
|
|
271
|
+
session: mockSessionData,
|
|
272
|
+
});
|
|
273
|
+
}));
|
|
274
|
+
it('should return false on error', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
275
|
+
mockAuth.signInWithPassword.mockResolvedValue({
|
|
276
|
+
data: null,
|
|
277
|
+
error: { message: 'Invalid credentials' },
|
|
278
|
+
});
|
|
279
|
+
const result = yield adapter.signup('test@example.com', 'wrongpass');
|
|
280
|
+
expect(result).toBe(false);
|
|
281
|
+
}));
|
|
282
|
+
});
|
|
283
|
+
describe('signout()', () => {
|
|
284
|
+
it('should successfully sign out', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
285
|
+
mockAuth.signOut.mockResolvedValue({
|
|
286
|
+
error: null,
|
|
287
|
+
});
|
|
288
|
+
const result = yield adapter.signout();
|
|
289
|
+
expect(mockAuth.signOut).toHaveBeenCalled();
|
|
290
|
+
expect(result).toBe(true);
|
|
291
|
+
}));
|
|
292
|
+
it('should return false on error', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
293
|
+
mockAuth.signOut.mockResolvedValue({
|
|
294
|
+
error: { message: 'Signout failed' },
|
|
295
|
+
});
|
|
296
|
+
const result = yield adapter.signout();
|
|
297
|
+
expect(result).toBe(false);
|
|
298
|
+
}));
|
|
299
|
+
});
|
|
300
|
+
describe('update()', () => {
|
|
301
|
+
const mockUser = {
|
|
302
|
+
uid: 'user-123',
|
|
303
|
+
_: {
|
|
304
|
+
email: 'test@example.com',
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
it('should successfully update user', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
308
|
+
const updatable = {
|
|
309
|
+
email: 'newemail@example.com',
|
|
310
|
+
displayName: 'New Name',
|
|
311
|
+
};
|
|
312
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
313
|
+
data: { user: Object.assign(Object.assign({}, mockUser), updatable) },
|
|
314
|
+
error: null,
|
|
315
|
+
});
|
|
316
|
+
yield adapter.update(mockUser, updatable);
|
|
317
|
+
expect(mockAuthAdmin.updateUserById).toHaveBeenCalledWith('user-123', updatable);
|
|
318
|
+
}));
|
|
319
|
+
it('should skip update when updatable is empty', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
320
|
+
yield adapter.update(mockUser, {});
|
|
321
|
+
expect(mockAuthAdmin.updateUserById).not.toHaveBeenCalled();
|
|
322
|
+
}));
|
|
323
|
+
it('should handle error during update', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
324
|
+
const updatable = { displayName: 'New Name' };
|
|
325
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
326
|
+
data: null,
|
|
327
|
+
error: { message: 'Update failed' },
|
|
328
|
+
});
|
|
329
|
+
yield adapter.update(mockUser, updatable);
|
|
330
|
+
// The method doesn't throw, just logs the error
|
|
331
|
+
expect(mockAuthAdmin.updateUserById).toHaveBeenCalledWith('user-123', updatable);
|
|
332
|
+
}));
|
|
333
|
+
it('should return false on exception', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
334
|
+
const updatable = { displayName: 'New Name' };
|
|
335
|
+
mockAuthAdmin.updateUserById.mockRejectedValue(new Error('Network error'));
|
|
336
|
+
const result = yield adapter.update(mockUser, updatable);
|
|
337
|
+
expect(result).toBe(false);
|
|
338
|
+
}));
|
|
339
|
+
});
|
|
340
|
+
describe('delete()', () => {
|
|
341
|
+
it('should have delete method defined', () => {
|
|
342
|
+
expect(adapter.delete).toBeDefined();
|
|
343
|
+
expect(typeof adapter.delete).toBe('function');
|
|
344
|
+
});
|
|
345
|
+
it('should be callable with a user', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
346
|
+
const mockUser = {
|
|
347
|
+
uid: 'user-123',
|
|
348
|
+
};
|
|
349
|
+
// The method is currently not implemented (empty)
|
|
350
|
+
const result = yield adapter.delete(mockUser);
|
|
351
|
+
expect(result).toBeUndefined();
|
|
352
|
+
}));
|
|
353
|
+
});
|
|
354
|
+
describe('setCustomUserClaims()', () => {
|
|
355
|
+
it('should successfully set custom user claims', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
356
|
+
const claims = {
|
|
357
|
+
role: 'admin',
|
|
358
|
+
permissions: ['read', 'write'],
|
|
359
|
+
};
|
|
360
|
+
const mockUserData = {
|
|
361
|
+
id: 'user-123',
|
|
362
|
+
user_metadata: claims,
|
|
363
|
+
};
|
|
364
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
365
|
+
data: mockUserData,
|
|
366
|
+
error: null,
|
|
367
|
+
});
|
|
368
|
+
const result = yield adapter.setCustomUserClaims('user-123', claims);
|
|
369
|
+
expect(mockAuthAdmin.updateUserById).toHaveBeenCalledWith('user-123', {
|
|
370
|
+
user_metadata: claims,
|
|
371
|
+
});
|
|
372
|
+
expect(result).toEqual(mockUserData);
|
|
373
|
+
}));
|
|
374
|
+
it('should throw AuthenticationError on error', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
375
|
+
const claims = { role: 'admin' };
|
|
376
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
377
|
+
data: null,
|
|
378
|
+
error: { message: 'Update failed' },
|
|
379
|
+
});
|
|
380
|
+
yield expect(adapter.setCustomUserClaims('user-123', claims)).rejects.toThrow(auth_1.AuthenticationError);
|
|
381
|
+
}));
|
|
382
|
+
});
|
|
383
|
+
});
|
package/package.json
CHANGED
|
@@ -1,40 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
2
|
+
"name": "@quatrain/auth-supabase",
|
|
3
|
+
"version": "1.1.19",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"bun": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"LICENSE.md",
|
|
10
|
+
"src/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@quatrain/auth": "^1.1.20",
|
|
17
|
+
"@quatrain/backend": "^1.1.26",
|
|
18
|
+
"@supabase/supabase-js": "^2.87.3",
|
|
19
|
+
"node-fetch-native": "^1.6.4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@tsconfig/recommended": "^1.0.1",
|
|
23
|
+
"@types/jest": "^30.0.0",
|
|
24
|
+
"@types/node": "^22.10.1",
|
|
25
|
+
"jest": "^30.2.0",
|
|
26
|
+
"jest-node-exports-resolver": "^1.1.6",
|
|
27
|
+
"jest-serial-runner": "^1.2.1",
|
|
28
|
+
"trace-unhandled": "^2.0.1",
|
|
29
|
+
"ts-jest": "^29.4.1",
|
|
30
|
+
"ts-node": "^10.9.1",
|
|
31
|
+
"typescript": "^5.2.2"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test-ci": "jest --runInBand",
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"wbuild": "tsc --watch",
|
|
37
|
+
"bump-to": "yarn version"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { SupabaseAuthAdapter } from './SupabaseAuthAdapter'
|
|
2
|
+
import { User } from '@quatrain/backend'
|
|
3
|
+
import { AuthenticationError } from '@quatrain/auth'
|
|
4
|
+
import { createClient } from '@supabase/supabase-js'
|
|
5
|
+
import * as nativeFetch from 'node-fetch-native'
|
|
6
|
+
|
|
7
|
+
// Mock the dependencies
|
|
8
|
+
jest.mock('@supabase/supabase-js')
|
|
9
|
+
jest.mock('node-fetch-native')
|
|
10
|
+
|
|
11
|
+
describe('SupabaseAuthAdapter', () => {
|
|
12
|
+
let adapter: SupabaseAuthAdapter
|
|
13
|
+
let mockSupabaseClient: any
|
|
14
|
+
let mockAuthAdmin: any
|
|
15
|
+
let mockAuth: any
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
// Clear all mocks before each test
|
|
19
|
+
jest.clearAllMocks()
|
|
20
|
+
|
|
21
|
+
// Create mock Supabase client structure
|
|
22
|
+
mockAuthAdmin = {
|
|
23
|
+
createUser: jest.fn(),
|
|
24
|
+
updateUserById: jest.fn(),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
mockAuth = {
|
|
28
|
+
admin: mockAuthAdmin,
|
|
29
|
+
getUser: jest.fn(),
|
|
30
|
+
signInWithPassword: jest.fn(),
|
|
31
|
+
signOut: jest.fn(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
mockSupabaseClient = {
|
|
35
|
+
auth: mockAuth,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mock createClient to return our mock client
|
|
39
|
+
;(createClient as jest.Mock).mockReturnValue(mockSupabaseClient)
|
|
40
|
+
|
|
41
|
+
// Create adapter instance
|
|
42
|
+
adapter = new SupabaseAuthAdapter({
|
|
43
|
+
config: {
|
|
44
|
+
supabaseUrl: 'https://test.supabase.co',
|
|
45
|
+
supabaseKey: 'test-key-123',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
jest.restoreAllMocks()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('Constructor', () => {
|
|
55
|
+
it('should create Supabase client with correct configuration', () => {
|
|
56
|
+
expect(createClient).toHaveBeenCalledWith(
|
|
57
|
+
'https://test.supabase.co',
|
|
58
|
+
'test-key-123',
|
|
59
|
+
{
|
|
60
|
+
auth: {
|
|
61
|
+
autoRefreshToken: false,
|
|
62
|
+
persistSession: false,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should set autoRefreshToken to false', () => {
|
|
69
|
+
const callArgs = (createClient as jest.Mock).mock.calls[0]
|
|
70
|
+
expect(callArgs[2].auth.autoRefreshToken).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should set persistSession to false', () => {
|
|
74
|
+
const callArgs = (createClient as jest.Mock).mock.calls[0]
|
|
75
|
+
expect(callArgs[2].auth.persistSession).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('register()', () => {
|
|
80
|
+
const mockUser = {
|
|
81
|
+
_: {
|
|
82
|
+
name: 'John Doe',
|
|
83
|
+
email: 'john@example.com',
|
|
84
|
+
phone: '+1234567890',
|
|
85
|
+
password: 'hashed-password',
|
|
86
|
+
},
|
|
87
|
+
} as unknown as User
|
|
88
|
+
|
|
89
|
+
it('should successfully register a new user', async () => {
|
|
90
|
+
const mockSupabaseUser = {
|
|
91
|
+
id: 'user-123',
|
|
92
|
+
email: 'john@example.com',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
96
|
+
data: { user: mockSupabaseUser },
|
|
97
|
+
error: null,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const result = await adapter.register(mockUser, 'clearPassword123')
|
|
101
|
+
|
|
102
|
+
expect(mockAuthAdmin.createUser).toHaveBeenCalledWith({
|
|
103
|
+
email: 'john@example.com',
|
|
104
|
+
password: 'clearPassword123',
|
|
105
|
+
email_confirm: true,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual(mockSupabaseUser)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should use hashed password when clearPassword is not provided', async () => {
|
|
112
|
+
const mockSupabaseUser = {
|
|
113
|
+
id: 'user-123',
|
|
114
|
+
email: 'john@example.com',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
118
|
+
data: { user: mockSupabaseUser },
|
|
119
|
+
error: null,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
await adapter.register(mockUser)
|
|
123
|
+
|
|
124
|
+
expect(mockAuthAdmin.createUser).toHaveBeenCalledWith({
|
|
125
|
+
email: 'john@example.com',
|
|
126
|
+
password: 'hashed-password',
|
|
127
|
+
email_confirm: true,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should throw AuthenticationError when email already exists', async () => {
|
|
132
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
133
|
+
data: null,
|
|
134
|
+
error: {
|
|
135
|
+
code: 'email_exists',
|
|
136
|
+
message: 'User already registered',
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await expect(adapter.register(mockUser, 'password')).rejects.toThrow(
|
|
141
|
+
AuthenticationError
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await expect(adapter.register(mockUser, 'password')).rejects.toThrow(
|
|
145
|
+
'User email already exists'
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should throw AuthenticationError on generic error', async () => {
|
|
150
|
+
mockAuthAdmin.createUser.mockResolvedValue({
|
|
151
|
+
data: null,
|
|
152
|
+
error: {
|
|
153
|
+
code: 'server_error',
|
|
154
|
+
message: 'Internal server error',
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
await expect(adapter.register(mockUser, 'password')).rejects.toThrow(
|
|
159
|
+
AuthenticationError
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should handle exceptions and wrap them in AuthenticationError', async () => {
|
|
164
|
+
mockAuthAdmin.createUser.mockRejectedValue(new Error('Network error'))
|
|
165
|
+
|
|
166
|
+
await expect(adapter.register(mockUser, 'password')).rejects.toThrow(
|
|
167
|
+
AuthenticationError
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
await expect(adapter.register(mockUser, 'password')).rejects.toThrow(
|
|
171
|
+
'Network error'
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('getAuthToken()', () => {
|
|
177
|
+
it('should successfully retrieve auth token', async () => {
|
|
178
|
+
const mockUserData = {
|
|
179
|
+
id: 'user-123',
|
|
180
|
+
email: 'test@example.com',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
mockAuth.getUser.mockResolvedValue({
|
|
184
|
+
data: { user: mockUserData },
|
|
185
|
+
error: null,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const result = await adapter.getAuthToken('bearer-token-123')
|
|
189
|
+
|
|
190
|
+
expect(mockAuth.getUser).toHaveBeenCalledWith('bearer-token-123')
|
|
191
|
+
expect(result).toEqual(mockUserData)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should throw error when user data is missing', async () => {
|
|
195
|
+
mockAuth.getUser.mockResolvedValue({
|
|
196
|
+
data: null,
|
|
197
|
+
error: null,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await expect(adapter.getAuthToken('invalid-token')).rejects.toThrow(
|
|
201
|
+
'Unable to retrieve auth token from Supabase'
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should throw error when user is undefined', async () => {
|
|
206
|
+
mockAuth.getUser.mockResolvedValue({
|
|
207
|
+
data: { user: null },
|
|
208
|
+
error: null,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await expect(adapter.getAuthToken('invalid-token')).rejects.toThrow(
|
|
212
|
+
'Unable to retrieve auth token from Supabase'
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('refreshToken()', () => {
|
|
218
|
+
it('should successfully refresh token', async () => {
|
|
219
|
+
const mockResponse = {
|
|
220
|
+
access_token: 'new-token',
|
|
221
|
+
refresh_token: 'new-refresh-token',
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const mockFetchResponse = {
|
|
225
|
+
json: jest.fn().mockResolvedValue(mockResponse),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockFetchResponse)
|
|
229
|
+
|
|
230
|
+
const result = await adapter.refreshToken('old-refresh-token')
|
|
231
|
+
|
|
232
|
+
expect(nativeFetch.fetch).toHaveBeenCalledWith(
|
|
233
|
+
'https://test.supabase.co/auth/v1/token?grant_type=refresh_token',
|
|
234
|
+
{
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: {
|
|
237
|
+
apikey: 'test-key-123',
|
|
238
|
+
},
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
refresh_token: 'old-refresh-token',
|
|
241
|
+
}),
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
// Note: The actual implementation doesn't await json(), so result is the promise
|
|
246
|
+
// But since we mocked it to return the value, we get the direct value
|
|
247
|
+
const resultValue = await result
|
|
248
|
+
expect(resultValue).toEqual(mockResponse)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should construct correct URL with config values', async () => {
|
|
252
|
+
const mockFetchResponse = {
|
|
253
|
+
json: jest.fn().mockResolvedValue({}),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
;(nativeFetch.fetch as jest.Mock).mockResolvedValue(mockFetchResponse)
|
|
257
|
+
|
|
258
|
+
await adapter.refreshToken('refresh-token')
|
|
259
|
+
|
|
260
|
+
const callArgs = (nativeFetch.fetch as jest.Mock).mock.calls[0]
|
|
261
|
+
expect(callArgs[0]).toBe(
|
|
262
|
+
'https://test.supabase.co/auth/v1/token?grant_type=refresh_token'
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe('revokeAuthToken()', () => {
|
|
268
|
+
it('should successfully revoke token via signout', async () => {
|
|
269
|
+
mockAuth.signOut.mockResolvedValue({
|
|
270
|
+
error: null,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const result = await adapter.revokeAuthToken('token-123')
|
|
274
|
+
|
|
275
|
+
expect(mockAuth.signOut).toHaveBeenCalled()
|
|
276
|
+
// Note: The implementation has a bug - it destructures { error } from signout()
|
|
277
|
+
// which returns true/false, not an object. So error is undefined, and
|
|
278
|
+
// undefined !== null is true, so it returns false even on success.
|
|
279
|
+
expect(result).toBe(false)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should return false when signout fails', async () => {
|
|
283
|
+
mockAuth.signOut.mockResolvedValue({
|
|
284
|
+
error: { message: 'Signout failed' },
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const result = await adapter.revokeAuthToken('token-123')
|
|
288
|
+
|
|
289
|
+
expect(result).toBe(false)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe('signup()', () => {
|
|
294
|
+
it('should successfully sign in with email and password', async () => {
|
|
295
|
+
const mockUserData = {
|
|
296
|
+
id: 'user-123',
|
|
297
|
+
email: 'test@example.com',
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const mockSessionData = {
|
|
301
|
+
access_token: 'token-123',
|
|
302
|
+
refresh_token: 'refresh-123',
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
mockAuth.signInWithPassword.mockResolvedValue({
|
|
306
|
+
data: {
|
|
307
|
+
user: mockUserData,
|
|
308
|
+
session: mockSessionData,
|
|
309
|
+
},
|
|
310
|
+
error: null,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const result = await adapter.signup('test@example.com', 'password123')
|
|
314
|
+
|
|
315
|
+
expect(mockAuth.signInWithPassword).toHaveBeenCalledWith({
|
|
316
|
+
email: 'test@example.com',
|
|
317
|
+
password: 'password123',
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
expect(result).toEqual({
|
|
321
|
+
user: mockUserData,
|
|
322
|
+
session: mockSessionData,
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('should return false on error', async () => {
|
|
327
|
+
mockAuth.signInWithPassword.mockResolvedValue({
|
|
328
|
+
data: null,
|
|
329
|
+
error: { message: 'Invalid credentials' },
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const result = await adapter.signup('test@example.com', 'wrongpass')
|
|
333
|
+
|
|
334
|
+
expect(result).toBe(false)
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe('signout()', () => {
|
|
339
|
+
it('should successfully sign out', async () => {
|
|
340
|
+
mockAuth.signOut.mockResolvedValue({
|
|
341
|
+
error: null,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const result = await adapter.signout()
|
|
345
|
+
|
|
346
|
+
expect(mockAuth.signOut).toHaveBeenCalled()
|
|
347
|
+
expect(result).toBe(true)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should return false on error', async () => {
|
|
351
|
+
mockAuth.signOut.mockResolvedValue({
|
|
352
|
+
error: { message: 'Signout failed' },
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const result = await adapter.signout()
|
|
356
|
+
|
|
357
|
+
expect(result).toBe(false)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
describe('update()', () => {
|
|
362
|
+
const mockUser = {
|
|
363
|
+
uid: 'user-123',
|
|
364
|
+
_: {
|
|
365
|
+
email: 'test@example.com',
|
|
366
|
+
},
|
|
367
|
+
} as unknown as User
|
|
368
|
+
|
|
369
|
+
it('should successfully update user', async () => {
|
|
370
|
+
const updatable = {
|
|
371
|
+
email: 'newemail@example.com',
|
|
372
|
+
displayName: 'New Name',
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
376
|
+
data: { user: { ...mockUser, ...updatable } },
|
|
377
|
+
error: null,
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
await adapter.update(mockUser, updatable)
|
|
381
|
+
|
|
382
|
+
expect(mockAuthAdmin.updateUserById).toHaveBeenCalledWith(
|
|
383
|
+
'user-123',
|
|
384
|
+
updatable
|
|
385
|
+
)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('should skip update when updatable is empty', async () => {
|
|
389
|
+
await adapter.update(mockUser, {})
|
|
390
|
+
|
|
391
|
+
expect(mockAuthAdmin.updateUserById).not.toHaveBeenCalled()
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('should handle error during update', async () => {
|
|
395
|
+
const updatable = { displayName: 'New Name' }
|
|
396
|
+
|
|
397
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
398
|
+
data: null,
|
|
399
|
+
error: { message: 'Update failed' },
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
await adapter.update(mockUser, updatable)
|
|
403
|
+
|
|
404
|
+
// The method doesn't throw, just logs the error
|
|
405
|
+
expect(mockAuthAdmin.updateUserById).toHaveBeenCalledWith(
|
|
406
|
+
'user-123',
|
|
407
|
+
updatable
|
|
408
|
+
)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('should return false on exception', async () => {
|
|
412
|
+
const updatable = { displayName: 'New Name' }
|
|
413
|
+
|
|
414
|
+
mockAuthAdmin.updateUserById.mockRejectedValue(
|
|
415
|
+
new Error('Network error')
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
const result = await adapter.update(mockUser, updatable)
|
|
419
|
+
|
|
420
|
+
expect(result).toBe(false)
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('delete()', () => {
|
|
425
|
+
it('should have delete method defined', () => {
|
|
426
|
+
expect(adapter.delete).toBeDefined()
|
|
427
|
+
expect(typeof adapter.delete).toBe('function')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('should be callable with a user', async () => {
|
|
431
|
+
const mockUser = {
|
|
432
|
+
uid: 'user-123',
|
|
433
|
+
} as unknown as User
|
|
434
|
+
|
|
435
|
+
// The method is currently not implemented (empty)
|
|
436
|
+
const result = await adapter.delete(mockUser)
|
|
437
|
+
|
|
438
|
+
expect(result).toBeUndefined()
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
describe('setCustomUserClaims()', () => {
|
|
443
|
+
it('should successfully set custom user claims', async () => {
|
|
444
|
+
const claims = {
|
|
445
|
+
role: 'admin',
|
|
446
|
+
permissions: ['read', 'write'],
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const mockUserData = {
|
|
450
|
+
id: 'user-123',
|
|
451
|
+
user_metadata: claims,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
455
|
+
data: mockUserData,
|
|
456
|
+
error: null,
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const result = await adapter.setCustomUserClaims('user-123', claims)
|
|
460
|
+
|
|
461
|
+
expect(mockAuthAdmin.updateUserById).toHaveBeenCalledWith('user-123', {
|
|
462
|
+
user_metadata: claims,
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
expect(result).toEqual(mockUserData)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('should throw AuthenticationError on error', async () => {
|
|
469
|
+
const claims = { role: 'admin' }
|
|
470
|
+
|
|
471
|
+
mockAuthAdmin.updateUserById.mockResolvedValue({
|
|
472
|
+
data: null,
|
|
473
|
+
error: { message: 'Update failed' },
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
await expect(
|
|
477
|
+
adapter.setCustomUserClaims('user-123', claims)
|
|
478
|
+
).rejects.toThrow(AuthenticationError)
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { User } from '@quatrain/backend'
|
|
2
|
+
import {
|
|
3
|
+
Auth,
|
|
4
|
+
AbstractAuthAdapter,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthParameters,
|
|
7
|
+
} from '@quatrain/auth'
|
|
8
|
+
import { createClient } from '@supabase/supabase-js'
|
|
9
|
+
import * as nativeFetch from 'node-fetch-native'
|
|
10
|
+
|
|
11
|
+
// Create a single supabase client for interacting with your database
|
|
12
|
+
export class SupabaseAuthAdapter extends AbstractAuthAdapter {
|
|
13
|
+
protected _client: any
|
|
14
|
+
|
|
15
|
+
constructor(params: AuthParameters = {}) {
|
|
16
|
+
super(params)
|
|
17
|
+
this._client = createClient(
|
|
18
|
+
params.config.supabaseUrl,
|
|
19
|
+
params.config.supabaseKey,
|
|
20
|
+
{
|
|
21
|
+
auth: {
|
|
22
|
+
autoRefreshToken: false,
|
|
23
|
+
persistSession: false,
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
Auth.info(`Created Supabase client for ${params.config.supabaseUrl}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register new user in authentication
|
|
32
|
+
* @param user
|
|
33
|
+
* @returns user unique id
|
|
34
|
+
*/
|
|
35
|
+
async register(user: User, clearPassword?: string) {
|
|
36
|
+
try {
|
|
37
|
+
const {
|
|
38
|
+
name: displayName,
|
|
39
|
+
email,
|
|
40
|
+
phone: phoneNumber,
|
|
41
|
+
password,
|
|
42
|
+
} = user._
|
|
43
|
+
|
|
44
|
+
Auth.info(`[SAA] Adding user '${displayName}'`)
|
|
45
|
+
const { data, error } = await this._client.auth.admin.createUser({
|
|
46
|
+
email,
|
|
47
|
+
password: clearPassword || password,
|
|
48
|
+
email_confirm: true, // TODO move to params
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (error) {
|
|
52
|
+
Auth.error(error.message)
|
|
53
|
+
if (error.code === 'email_exists') {
|
|
54
|
+
throw new AuthenticationError(Auth.ERROR_EMAIL_EXISTS)
|
|
55
|
+
}
|
|
56
|
+
throw new Error(error)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return data.user
|
|
60
|
+
} catch (err) {
|
|
61
|
+
Auth.error(err)
|
|
62
|
+
throw new AuthenticationError((err as Error).message)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getAuthToken(bearer: string) {
|
|
67
|
+
const token = await this._client.auth.getUser(bearer)
|
|
68
|
+
if (token.data && token.data.user) {
|
|
69
|
+
return token.data.user
|
|
70
|
+
}
|
|
71
|
+
throw new Error('Unable to retrieve auth token from Supabase')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async refreshToken(refreshToken: string) {
|
|
75
|
+
const url = `${this._params.config.supabaseUrl}/auth/v1/token?grant_type=refresh_token`
|
|
76
|
+
const response = await nativeFetch.fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
apikey: this._params.config.supabaseKey,
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
refresh_token: refreshToken,
|
|
83
|
+
}),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const data = response.json()
|
|
87
|
+
|
|
88
|
+
return data
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async revokeAuthToken(token: string) {
|
|
92
|
+
// Careful, this only delete tokens on client side, not on server side
|
|
93
|
+
const { error } = await this.signout()
|
|
94
|
+
if (error !== null) {
|
|
95
|
+
Auth.error(error)
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async signup(login: string, password: string) {
|
|
101
|
+
const { data, error } = await this._client.auth.signInWithPassword({
|
|
102
|
+
email: login,
|
|
103
|
+
password,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (error !== null) {
|
|
107
|
+
Auth.error(error)
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { user: data.user, session: data.session }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async signout(): Promise<any> {
|
|
115
|
+
const { error } = await this._client.auth.signOut()
|
|
116
|
+
if (error !== null) {
|
|
117
|
+
Auth.error(error)
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async update(user: User, updatable: any): Promise<any> {
|
|
124
|
+
Auth.debug('auth data to update', JSON.stringify(updatable))
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
if (Object.keys(updatable).length > 0) {
|
|
128
|
+
Auth.info(`Updating ${updatable.displayName} Auth record`)
|
|
129
|
+
const { error } = await this._client.auth.admin.updateUserById(
|
|
130
|
+
user.uid,
|
|
131
|
+
updatable
|
|
132
|
+
)
|
|
133
|
+
if (error !== null) {
|
|
134
|
+
Auth.error(error)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
Auth.error(e)
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async delete(user: User): Promise<any> {
|
|
144
|
+
// return await getAuth().deleteUser(user.uid)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async setCustomUserClaims(id: string, claims: any) {
|
|
148
|
+
Auth.debug(`Updating user ${id} with claims ${JSON.stringify(claims)}`)
|
|
149
|
+
const { data, error } = await this._client.auth.admin.updateUserById(id, {
|
|
150
|
+
user_metadata: claims,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (error) {
|
|
154
|
+
throw new AuthenticationError(error)
|
|
155
|
+
} else {
|
|
156
|
+
return data
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/index.ts
ADDED