@svadmin/supabase 0.13.9 → 0.15.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.
@@ -1,171 +1,310 @@
1
- // Supabase AuthProvider
2
- import type { SupabaseClient, User } from '@supabase/supabase-js';
1
+ import { definedOptions } from '@svadmin/core/options';
2
+ import type { Static } from '@sinclair/typebox';
3
+ import { snapshotPlainData } from '@svadmin/core/schema';
4
+ import { audit } from '@svadmin/core/audit';
3
5
  import type { AuthProvider, Identity, AuthActionResult, CheckResult } from '@svadmin/core';
4
- import { audit } from '@svadmin/core';
6
+ import {
7
+ authRequest, credentials, decodeAuth, inspectAuthError, loginResponse, passwordUpdate,
8
+ registration, registrationResponse, resetRequest, resetResponse, sessionResponse,
9
+ signOutResponse, SupabaseAuthError, updateResponse, userResponse, validateIdentifier,
10
+ type SupabaseAuthSession, type SupabaseAuthUser,
11
+ } from './auth-contract';
5
12
 
6
- export interface SupabasePermissionResolverContext {
7
- client: SupabaseClient;
8
- user: User;
13
+ export interface SupabaseAuthClient {
14
+ readonly auth: {
15
+ signInWithPassword(credentials: { email: string; password: string }): Promise<unknown>;
16
+ signUp(credentials: { email: string; password: string; options: { data: Record<string, unknown> } }): Promise<unknown>;
17
+ signOut(options?: { scope: 'local' | 'global' }): Promise<unknown>;
18
+ getSession(): Promise<unknown>;
19
+ getUser(jwt?: string): Promise<unknown>;
20
+ resetPasswordForEmail(email: string): Promise<unknown>;
21
+ updateUser(attributes: { password: string }): Promise<unknown>;
22
+ };
23
+ }
24
+
25
+ export interface SupabasePermissionResolverContext<C extends SupabaseAuthClient = SupabaseAuthClient> {
26
+ client: C;
27
+ user: SupabaseAuthUser;
9
28
  }
10
29
 
11
- export type SupabasePermissionResolver = (
12
- context: SupabasePermissionResolverContext,
30
+ export type SupabasePermissionResolver<C extends SupabaseAuthClient = SupabaseAuthClient> = (
31
+ context: SupabasePermissionResolverContext<C>,
13
32
  ) => Promise<unknown> | unknown;
14
33
 
15
- export interface SupabaseAuthProviderOptions {
16
- getPermissions?: SupabasePermissionResolver;
34
+ export interface SupabaseAuthProviderOptions<C extends SupabaseAuthClient = SupabaseAuthClient> {
35
+ getPermissions?: SupabasePermissionResolver<C>;
17
36
  }
18
37
 
19
- const INVALID_REFRESH_TOKEN_MESSAGES = [
20
- 'refresh token is not valid',
21
- 'invalid refresh token',
22
- 'refresh_token_not_found',
23
- 'jwt expired',
24
- ];
25
-
26
- function isInvalidRefreshTokenError(error: unknown): error is Error {
27
- if (!(error instanceof Error)) {
28
- return false;
29
- }
38
+ function safeError(error: unknown): SupabaseAuthError {
39
+ try { if (error instanceof SupabaseAuthError) return error; }
40
+ catch { /* Untrusted exceptions may have throwing prototypes. */ }
41
+ return new SupabaseAuthError('AUTH_REQUEST_FAILED');
42
+ }
30
43
 
31
- const message = error.message.toLowerCase();
32
- return INVALID_REFRESH_TOKEN_MESSAGES.some((fragment) => message.includes(fragment));
44
+ function actionFailure(error: unknown): AuthActionResult {
45
+ const failure = safeError(error);
46
+ return { success: false, error: { name: failure.code, message: failure.message } };
33
47
  }
34
48
 
35
- async function clearInvalidSession(client: SupabaseClient): Promise<void> {
36
- await client.auth.signOut({ scope: 'local' });
49
+ function requiresCleanup(error: SupabaseAuthError): boolean {
50
+ return error.code === 'INVALID_AUTH_SESSION' || error.code === 'AUTH_SESSION_EXPIRED';
37
51
  }
38
52
 
39
- async function currentPermissionUser(client: SupabaseClient): Promise<User | null> {
40
- const { data: { user }, error } = await client.auth.getUser();
41
- if (error && isInvalidRefreshTokenError(error)) {
42
- await clearInvalidSession(client);
43
- return null;
44
- }
45
- if (error) throw error;
46
- return user ?? null;
53
+ function requireFreshSession(session: SupabaseAuthSession): void {
54
+ if (session.expires_at <= Math.floor(Date.now() / 1000)) throw new SupabaseAuthError('AUTH_SESSION_EXPIRED');
47
55
  }
48
56
 
49
- export function createSupabaseAuthProvider(
50
- client: SupabaseClient,
51
- options: SupabaseAuthProviderOptions = {},
57
+ /** Authentication and permissions use schema-validated snapshots, not SDK metadata assertions. */
58
+ export function createSupabaseAuthProvider<C extends SupabaseAuthClient>(
59
+ client: C, options: SupabaseAuthProviderOptions<C> = {},
52
60
  ): AuthProvider {
61
+ let revision = 0;
62
+ let mutations: Promise<void> = Promise.resolve();
63
+
64
+ // Serialize this provider's writes so a late login cannot undo a later logout.
65
+ function mutate(work: () => Promise<AuthActionResult>): Promise<AuthActionResult> {
66
+ revision++;
67
+ const result = mutations.then(work).catch(actionFailure);
68
+ mutations = result.then(() => {});
69
+ return result;
70
+ }
71
+
72
+ async function readSession(): Promise<SupabaseAuthSession | null> {
73
+ const result = await authRequest(() => client.auth.getSession(), sessionResponse, 'session');
74
+ if (result.data.session) requireFreshSession(result.data.session);
75
+ return result.data.session;
76
+ }
77
+
78
+ async function signOut(scope: 'local' | 'global'): Promise<void> {
79
+ try {
80
+ await authRequest(() => client.auth.signOut({ scope }), signOutResponse, 'response', true);
81
+ const result = await authRequest(() => client.auth.getSession(), sessionResponse, 'session');
82
+ if (result.data.session !== null) throw new SupabaseAuthError('AUTH_CLEANUP_FAILED');
83
+ } catch {
84
+ throw new SupabaseAuthError('AUTH_CLEANUP_FAILED');
85
+ }
86
+ }
87
+
88
+ async function clearInvalidRead(expectedRevision: number): Promise<void> {
89
+ if (revision !== expectedRevision) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
90
+ const cleanupRevision = expectedRevision + 1;
91
+ const result = await mutate(async () => {
92
+ if (revision !== cleanupRevision) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
93
+ await signOut('local');
94
+ return { success: true };
95
+ });
96
+ if (revision !== cleanupRevision) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
97
+ if (!result.success) throw new SupabaseAuthError('AUTH_CLEANUP_FAILED');
98
+ }
99
+
100
+ async function assertCurrent(session: SupabaseAuthSession, expectedRevision: number): Promise<void> {
101
+ if (revision !== expectedRevision) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
102
+ const current = await readSession();
103
+ if (revision !== expectedRevision || current === null ||
104
+ current.access_token !== session.access_token || current.user.id !== session.user.id) {
105
+ throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
106
+ }
107
+ }
108
+
109
+ async function principal(expectedRevision: number) {
110
+ const session = await readSession();
111
+ if (revision !== expectedRevision) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
112
+ if (session === null) return null;
113
+ let user: SupabaseAuthUser | null;
114
+ try {
115
+ const result = await authRequest(() => client.auth.getUser(session.access_token), userResponse, 'session');
116
+ user = result.data.user;
117
+ } catch (error) {
118
+ await assertCurrent(session, expectedRevision);
119
+ throw error;
120
+ }
121
+ await assertCurrent(session, expectedRevision);
122
+ if (user === null || user.id !== session.user.id) throw new SupabaseAuthError('INVALID_AUTH_SESSION');
123
+ return { session, user };
124
+ }
125
+
126
+ async function readPrincipal() {
127
+ const pending = mutations;
128
+ await pending;
129
+ if (mutations !== pending) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
130
+ const expectedRevision = revision;
131
+ try {
132
+ const current = await principal(expectedRevision);
133
+ return current === null ? null : { ...current, revision: expectedRevision };
134
+ } catch (error) {
135
+ if (revision !== expectedRevision) throw new SupabaseAuthError('AUTH_SESSION_CHANGED');
136
+ const failure = safeError(error);
137
+ if (requiresCleanup(failure)) await clearInvalidRead(expectedRevision);
138
+ throw failure;
139
+ }
140
+ }
141
+
53
142
  return {
54
- async login({ email, password }: Record<string, unknown>): Promise<AuthActionResult> {
55
- const { data, error } = await client.auth.signInWithPassword({
56
- email: email as string,
57
- password: password as string,
143
+ async login(params): Promise<AuthActionResult> {
144
+ let input: Static<typeof credentials>;
145
+ try {
146
+ input = decodeAuth(credentials, params, 'input');
147
+ validateIdentifier(input);
148
+ } catch (error) { return actionFailure(error); }
149
+ const credentialsSnapshot = { email: input.email, password: input.password };
150
+ return mutate(async () => {
151
+ try {
152
+ const { data } = await authRequest(
153
+ () => client.auth.signInWithPassword(credentialsSnapshot), loginResponse, 'response', true,
154
+ );
155
+ requireFreshSession(data.session);
156
+ if (data.session.user.id !== data.user.id) throw new SupabaseAuthError('INVALID_AUTH_SESSION');
157
+ audit({ action: 'login', userId: data.user.id });
158
+ return { success: true, redirectTo: '/' };
159
+ } catch (error) {
160
+ const failure = safeError(error);
161
+ if (failure.code === 'WRITE_OUTCOME_UNKNOWN' || requiresCleanup(failure)) await signOut('local');
162
+ throw failure;
163
+ }
58
164
  });
59
- if (error) return { success: false, error: { message: error.message } };
60
- audit({ action: 'login', userId: data.user?.id });
61
- return { success: true, redirectTo: '/' };
62
165
  },
63
166
 
64
- async logout(): Promise<AuthActionResult> {
65
- const { data: { user } } = await client.auth.getUser();
66
- await client.auth.signOut();
67
- audit({ action: 'logout', userId: user?.id });
68
- return { success: true, redirectTo: '/login' };
167
+ logout(): Promise<AuthActionResult> {
168
+ return mutate(async () => {
169
+ let userId: string | undefined;
170
+ try {
171
+ const current = await principal(revision);
172
+ userId = current?.user.id;
173
+ } catch {
174
+ // An unavailable identity lookup must not prevent a requested sign-out.
175
+ }
176
+ await signOut('global');
177
+ audit(definedOptions({ action: 'logout', userId }));
178
+ return { success: true, redirectTo: '/login' };
179
+ });
69
180
  },
70
181
 
71
182
  async check(): Promise<CheckResult> {
72
- const { data: { session }, error } = await client.auth.getSession();
73
- if (error) {
74
- if (isInvalidRefreshTokenError(error)) {
75
- await clearInvalidSession(client);
76
- return {
77
- authenticated: false,
78
- redirectTo: '/login',
79
- logout: true,
80
- error: { message: 'Session expired, please sign in again.' },
81
- };
82
- }
83
-
183
+ try {
184
+ return await readPrincipal()
185
+ ? { authenticated: true }
186
+ : { authenticated: false, redirectTo: '/login', logout: true };
187
+ } catch (error) {
188
+ const failure = safeError(error);
84
189
  return {
85
190
  authenticated: false,
86
- redirectTo: '/login',
87
- logout: true,
88
- error: { message: 'Session validation failed.' },
191
+ ...definedOptions({
192
+ redirectTo: requiresCleanup(failure) || failure.code === 'AUTH_CLEANUP_FAILED' ? '/login' : undefined,
193
+ logout: requiresCleanup(failure) || failure.code === 'AUTH_CLEANUP_FAILED' ? true : undefined,
194
+ }),
195
+ error: { name: failure.code, message: failure.message },
89
196
  };
90
197
  }
91
- if (session) return { authenticated: true };
92
- return { authenticated: false, redirectTo: '/login', logout: true };
93
198
  },
94
199
 
95
200
  async getIdentity(): Promise<Identity | null> {
96
- const { data: { user }, error } = await client.auth.getUser();
97
- if (error && isInvalidRefreshTokenError(error)) {
98
- await clearInvalidSession(client);
99
- return null;
100
- }
101
- if (!user) return null;
102
-
103
- const { data: { session }, error: sessionError } = await client.auth.getSession();
104
- if (sessionError && isInvalidRefreshTokenError(sessionError)) {
105
- await clearInvalidSession(client);
106
- return null;
201
+ try {
202
+ const current = await readPrincipal();
203
+ if (!current) return null;
204
+ const { user, session } = current;
205
+ return definedOptions({
206
+ id: user.id,
207
+ name: user.user_metadata.name || user.email?.split('@')[0] || user.id,
208
+ email: user.email,
209
+ avatar: user.user_metadata.avatar_url ?? undefined,
210
+ token: session.access_token,
211
+ });
212
+ } catch (error) {
213
+ const failure = safeError(error);
214
+ if (requiresCleanup(failure) || failure.code === 'AUTH_SESSION_CHANGED') return null;
215
+ throw failure;
107
216
  }
108
-
109
- return {
110
- id: user.id,
111
- name: user.user_metadata?.name ?? user.email?.split('@')[0] ?? 'User',
112
- email: user.email,
113
- avatar: user.user_metadata?.avatar_url,
114
- token: session?.access_token,
115
- };
116
217
  },
117
218
 
118
219
  async getPermissions(): Promise<unknown> {
119
220
  if (!options.getPermissions) return null;
120
- const user = await currentPermissionUser(client);
121
- if (!user) return null;
122
- return options.getPermissions({ client, user });
221
+ let current: Awaited<ReturnType<typeof readPrincipal>>;
222
+ try { current = await readPrincipal(); }
223
+ catch (error) {
224
+ const failure = safeError(error);
225
+ if (requiresCleanup(failure) || failure.code === 'AUTH_SESSION_CHANGED') return null;
226
+ throw failure;
227
+ }
228
+ if (!current) return null;
229
+ let result: unknown;
230
+ try { result = await options.getPermissions({ client, user: current.user }); }
231
+ catch { throw new SupabaseAuthError('PERMISSION_LOOKUP_FAILED'); }
232
+ let permissions: unknown;
233
+ try { permissions = snapshotPlainData(result); }
234
+ catch { throw new SupabaseAuthError('INVALID_PERMISSIONS'); }
235
+ try { await assertCurrent(current.session, current.revision); }
236
+ catch (error) {
237
+ const failure = safeError(error);
238
+ if (failure.code === 'AUTH_SESSION_CHANGED') return null;
239
+ throw failure;
240
+ }
241
+ return permissions;
123
242
  },
124
243
 
125
- async register({ email, password, ...rest }: Record<string, unknown>): Promise<AuthActionResult> {
126
- const { error } = await client.auth.signUp({
127
- email: email as string,
128
- password: password as string,
129
- options: { data: rest },
244
+ async register(params): Promise<AuthActionResult> {
245
+ let input: Static<typeof registration>;
246
+ try {
247
+ input = decodeAuth(registration, params, 'input');
248
+ validateIdentifier(input);
249
+ } catch (error) { return actionFailure(error); }
250
+ const payload = {
251
+ email: input.email, password: input.password,
252
+ options: { data: definedOptions({ name: input.name, username: input.username }) },
253
+ };
254
+ return mutate(async () => {
255
+ try {
256
+ const { data } = await authRequest(() => client.auth.signUp(payload), registrationResponse, 'response', true);
257
+ if (data.session) {
258
+ requireFreshSession(data.session);
259
+ if (data.user.id !== data.session.user.id) throw new SupabaseAuthError('INVALID_AUTH_SESSION');
260
+ }
261
+ return { success: true, redirectTo: '/login' };
262
+ } catch (error) {
263
+ const failure = safeError(error);
264
+ if (failure.code === 'WRITE_OUTCOME_UNKNOWN' || requiresCleanup(failure)) await signOut('local');
265
+ throw failure;
266
+ }
130
267
  });
131
- if (error) return { success: false, error: { message: error.message } };
132
- return { success: true, redirectTo: '/login' };
133
- },
134
-
135
- async forgotPassword({ email }: Record<string, unknown>): Promise<AuthActionResult> {
136
- const { error } = await client.auth.resetPasswordForEmail(email as string);
137
- if (error) return { success: false, error: { message: error.message } };
138
- return { success: true };
139
268
  },
140
269
 
141
- async updatePassword({ password }: Record<string, unknown>): Promise<AuthActionResult> {
142
- const { error } = await client.auth.updateUser({ password: password as string });
143
- if (error) return { success: false, error: { message: error.message } };
144
- return { success: true, redirectTo: '/' };
270
+ async forgotPassword(params): Promise<AuthActionResult> {
271
+ try {
272
+ const input = decodeAuth(resetRequest, params, 'input');
273
+ validateIdentifier(input);
274
+ await authRequest(() => client.auth.resetPasswordForEmail(input.email), resetResponse, 'response', true);
275
+ return { success: true };
276
+ } catch (error) { return actionFailure(error); }
145
277
  },
146
278
 
147
- async onError(error: unknown): Promise<{ redirectTo?: string; logout?: boolean }> {
148
- if (isInvalidRefreshTokenError(error)) {
149
- await clearInvalidSession(client);
150
- return { redirectTo: '/login', logout: true };
151
- }
152
-
153
- if (error instanceof Error && error.message.includes('401')) {
154
- // Pre-flight check: race-condition guard
155
- // Supabase might have just refreshed the token in the background right after our fetch failed
156
- const { data: { session } } = await client.auth.getSession();
157
-
158
- if (session?.access_token) {
159
- // Token is actually still valid in Supabase! The 401 was a stale race condition.
160
- // We intercept and swallow the logout action so the user isn't falsely kicked.
161
- return {};
279
+ async updatePassword(params): Promise<AuthActionResult> {
280
+ let input: Static<typeof passwordUpdate>;
281
+ try {
282
+ input = decodeAuth(passwordUpdate, params, 'input');
283
+ if (input.confirmPassword !== undefined && input.password !== input.confirmPassword) {
284
+ throw new SupabaseAuthError('INVALID_AUTH_INPUT');
162
285
  }
286
+ } catch (error) { return actionFailure(error); }
287
+ const payload = { password: input.password };
288
+ return mutate(async () => {
289
+ const current = await principal(revision);
290
+ if (!current) throw new SupabaseAuthError('AUTH_SESSION_EXPIRED');
291
+ const { data } = await authRequest(() => client.auth.updateUser(payload), updateResponse, 'response', true);
292
+ if (data.user.id !== current.user.id) throw new SupabaseAuthError('WRITE_OUTCOME_UNKNOWN');
293
+ return { success: true, redirectTo: '/' };
294
+ });
295
+ },
163
296
 
164
- // Truly expired
165
- await client.auth.signOut();
166
- return { redirectTo: '/login', logout: true };
297
+ async onError(error): Promise<{ redirectTo?: string; logout?: boolean }> {
298
+ const kind = inspectAuthError(error);
299
+ if (kind !== 'expired' && kind !== 'unauthorized') return {};
300
+ try {
301
+ if (await readPrincipal()) return {};
302
+ } catch (failure) {
303
+ const normalized = safeError(failure);
304
+ if (normalized.code === 'AUTH_SESSION_CHANGED') return {};
305
+ if (!requiresCleanup(normalized)) throw normalized;
167
306
  }
168
- return {};
307
+ return { redirectTo: '/login', logout: true };
169
308
  },
170
309
  };
171
310
  }