@svadmin/supabase 0.13.8 → 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.
- package/package.json +7 -7
- package/src/audit-handler.ts +59 -10
- package/src/auth-contract.ts +169 -0
- package/src/auth-provider.ts +262 -123
- package/src/data-provider.ts +112 -178
- package/src/index.ts +16 -11
- package/src/live-provider.ts +193 -75
- package/src/permission-adapter.ts +28 -0
- package/src/rpc.ts +11 -29
- package/src/sdk-boundary.ts +79 -0
- package/src/supacloud.ts +220 -594
package/src/auth-provider.ts
CHANGED
|
@@ -1,171 +1,310 @@
|
|
|
1
|
-
|
|
2
|
-
import type {
|
|
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 {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
49
|
+
function requiresCleanup(error: SupabaseAuthError): boolean {
|
|
50
|
+
return error.code === 'INVALID_AUTH_SESSION' || error.code === 'AUTH_SESSION_EXPIRED';
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
}
|