@everystack/auth 0.4.1 → 0.4.2
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 +2 -2
- package/src/models/functions.ts +965 -0
- package/src/models/index.ts +33 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "JWT authentication handlers, password hashing, and React auth context",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
93
|
"bcryptjs": "3.0.3",
|
|
94
|
-
"@everystack/model": "0.4.
|
|
94
|
+
"@everystack/model": "0.4.12"
|
|
95
95
|
},
|
|
96
96
|
"peerDependencies": {
|
|
97
97
|
"@everystack/api": ">=0.1.0",
|
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth package's SECURITY DEFINER functions, as DECLARED derived objects.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. These same functions have always shipped, but on `defineModule`'s
|
|
5
|
+
* deprecated `functions` thunk — and exactly ONE command in the toolchain reads that thunk:
|
|
6
|
+
* `db:generate --init`, the greenfield generator (cli/migration-compile.ts, step 3). Every other
|
|
7
|
+
* lane, `db:reconcile` included, reads `module.derived`, which was empty. So an app that adopted
|
|
8
|
+
* brownfield — pull, sync, plan, apply, reconcile — installed NONE of these, and every gate
|
|
9
|
+
* reported clean, because a reconciler can only compare what was declared and nothing was.
|
|
10
|
+
* All seven auth models declare `writtenBy: 'functions'` (ENABLE-not-FORCE RLS on the reasoning
|
|
11
|
+
* that owner functions mediate their writes), so the declared posture and the live database
|
|
12
|
+
* disagreed about who mediates auth writes. Reported by a consumer, 2026-08-04.
|
|
13
|
+
*
|
|
14
|
+
* WHY defineFunction AND NOT defineSql. The escape hatch would have taken the whole blob in one
|
|
15
|
+
* descriptor and one content hash — but `defineSql` objects are deliberately SKIPPED by the
|
|
16
|
+
* search_path gates (model/src/derived.ts:526-534). Routing 24 SECURITY DEFINER functions through
|
|
17
|
+
* the one hatch that exempts them from the pinned-search_path check is the wrong trade for the
|
|
18
|
+
* auth package specifically. Declared as functions, each one is hashed, ordered, gate-checked,
|
|
19
|
+
* and — the part that matters most here — the compiler emits
|
|
20
|
+
* `REVOKE ALL ON FUNCTION … FROM PUBLIC` for EVERY function unconditionally
|
|
21
|
+
* (cli/derived-compile.ts:72). The defect that shipped in 0.4.0, seven functions left
|
|
22
|
+
* PUBLIC-executable because nobody remembered their REVOKE, becomes structurally impossible.
|
|
23
|
+
*
|
|
24
|
+
* HOW THESE WERE PRODUCED. Not transcribed by hand. The legacy `generateAuthSQL()` was applied to
|
|
25
|
+
* a scratch database and each descriptor was DERIVED from what PostgreSQL actually recorded —
|
|
26
|
+
* `pg_get_function_arguments`, `pg_get_function_result`, `prosrc`, `prosecdef`, `proconfig`,
|
|
27
|
+
* `provolatile`, and the ACL. Deriving beats transcribing when the cost of a mistyped argument
|
|
28
|
+
* type is a security bug. The derivation is then PROVEN, not trusted: the equivalence test in
|
|
29
|
+
* @everystack/cli builds one database from the thunk and one from these descriptors and compares
|
|
30
|
+
* the resulting catalogs function by function.
|
|
31
|
+
*
|
|
32
|
+
* Two derivation traps worth naming, both caught before this file was written:
|
|
33
|
+
* - the ACL always contains the function OWNER, so a naive read emits an ability granting
|
|
34
|
+
* EXECUTE to whatever role happened to build the scratch database;
|
|
35
|
+
* - `array_to_string(proconfig, ',')` is ambiguous with the commas INSIDE
|
|
36
|
+
* `search_path=auth, public, pg_temp`, silently truncating the pin to its first schema.
|
|
37
|
+
*
|
|
38
|
+
* The bodies are byte-identical to what the thunk produced. Edit `sql.ts` and re-derive; do not
|
|
39
|
+
* hand-patch a body here.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { defineFunction, arg, can, sql } from '@everystack/model';
|
|
43
|
+
|
|
44
|
+
export const changePasswordFn = defineFunction('auth.change_password', {
|
|
45
|
+
args: [arg('p_current_password', 'text'), arg('p_new_password', 'text')],
|
|
46
|
+
returns: 'jsonb',
|
|
47
|
+
language: 'plpgsql',
|
|
48
|
+
security: 'definer',
|
|
49
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
50
|
+
abilities: [can('execute', { role: 'authenticated' })],
|
|
51
|
+
body: sql`
|
|
52
|
+
DECLARE
|
|
53
|
+
v_user_id UUID;
|
|
54
|
+
v_hash TEXT;
|
|
55
|
+
BEGIN
|
|
56
|
+
-- The caller is authenticated; their verified subject claim identifies them.
|
|
57
|
+
-- Read the claim inline so this wrapper has no dependency on the accessor functions.
|
|
58
|
+
v_user_id := (nullif(current_setting('request.jwt.claims', true), '')::jsonb->>'sub')::uuid;
|
|
59
|
+
IF v_user_id IS NULL THEN
|
|
60
|
+
RAISE EXCEPTION 'not_authenticated' USING ERRCODE = 'P0001';
|
|
61
|
+
END IF;
|
|
62
|
+
|
|
63
|
+
SELECT encrypted_password INTO v_hash FROM auth.users
|
|
64
|
+
WHERE id = v_user_id AND deleted_at IS NULL;
|
|
65
|
+
IF NOT FOUND OR v_hash IS NULL THEN
|
|
66
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
67
|
+
END IF;
|
|
68
|
+
|
|
69
|
+
-- Verify the current password before changing it (the gate).
|
|
70
|
+
IF NOT auth.verify_password(p_current_password, v_hash) THEN
|
|
71
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
72
|
+
END IF;
|
|
73
|
+
|
|
74
|
+
UPDATE auth.users
|
|
75
|
+
SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
|
|
76
|
+
WHERE id = v_user_id;
|
|
77
|
+
|
|
78
|
+
-- A password change invalidates every existing session — force re-login everywhere.
|
|
79
|
+
PERFORM auth.revoke_refresh_tokens(v_user_id);
|
|
80
|
+
|
|
81
|
+
RETURN jsonb_build_object('changed', true, 'userId', v_user_id);
|
|
82
|
+
END;
|
|
83
|
+
`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const cleanupRefreshTokensFn = defineFunction('auth.cleanup_refresh_tokens', {
|
|
87
|
+
args: [arg('p_consumed_retention', 'interval', { default: sql`'7 days'::interval` })],
|
|
88
|
+
returns: 'integer',
|
|
89
|
+
language: 'plpgsql',
|
|
90
|
+
security: 'definer',
|
|
91
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
92
|
+
body: sql`
|
|
93
|
+
DECLARE
|
|
94
|
+
v_count INTEGER;
|
|
95
|
+
BEGIN
|
|
96
|
+
DELETE FROM auth.refresh_tokens
|
|
97
|
+
WHERE expires_at < now()
|
|
98
|
+
OR (status = 'consumed' AND created_at < now() - p_consumed_retention);
|
|
99
|
+
|
|
100
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
101
|
+
RETURN v_count;
|
|
102
|
+
END;
|
|
103
|
+
`,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const confirmEmailFn = defineFunction('auth.confirm_email', {
|
|
107
|
+
args: [arg('p_token', 'text')],
|
|
108
|
+
returns: 'jsonb',
|
|
109
|
+
language: 'plpgsql',
|
|
110
|
+
security: 'definer',
|
|
111
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
112
|
+
abilities: [can('execute', { role: 'anon' })],
|
|
113
|
+
body: sql`
|
|
114
|
+
DECLARE
|
|
115
|
+
v_result JSONB;
|
|
116
|
+
v_user_id UUID;
|
|
117
|
+
BEGIN
|
|
118
|
+
-- Raises on an invalid/expired/consumed token (fails closed). One-time use.
|
|
119
|
+
v_result := auth.verify_one_time_token(p_token, 'confirmation');
|
|
120
|
+
v_user_id := (v_result->>'userId')::uuid;
|
|
121
|
+
|
|
122
|
+
UPDATE auth.users
|
|
123
|
+
SET email_confirmed_at = now(), updated_at = now()
|
|
124
|
+
WHERE id = v_user_id;
|
|
125
|
+
|
|
126
|
+
RETURN jsonb_build_object('confirmed', true, 'userId', v_user_id);
|
|
127
|
+
END;
|
|
128
|
+
`,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export const createOneTimeTokenFn = defineFunction('auth.create_one_time_token', {
|
|
132
|
+
args: [arg('p_user_id', 'uuid'), arg('p_token_type', 'text'), arg('p_relates_to', 'text')],
|
|
133
|
+
returns: 'text',
|
|
134
|
+
language: 'plpgsql',
|
|
135
|
+
security: 'definer',
|
|
136
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
137
|
+
body: sql`
|
|
138
|
+
DECLARE
|
|
139
|
+
v_raw_token TEXT;
|
|
140
|
+
v_token_hash TEXT;
|
|
141
|
+
BEGIN
|
|
142
|
+
-- Delete any existing token of this type for this user
|
|
143
|
+
DELETE FROM auth.one_time_tokens
|
|
144
|
+
WHERE user_id = p_user_id AND token_type = p_token_type;
|
|
145
|
+
|
|
146
|
+
-- Generate 32 random bytes, base64url encoded
|
|
147
|
+
v_raw_token := rtrim(translate(
|
|
148
|
+
encode(gen_random_bytes(32), 'base64'),
|
|
149
|
+
'+/=', '-_ '
|
|
150
|
+
));
|
|
151
|
+
|
|
152
|
+
v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex');
|
|
153
|
+
|
|
154
|
+
INSERT INTO auth.one_time_tokens (user_id, token_type, token_hash, relates_to)
|
|
155
|
+
VALUES (p_user_id, p_token_type, v_token_hash, p_relates_to);
|
|
156
|
+
|
|
157
|
+
RETURN v_raw_token;
|
|
158
|
+
END;
|
|
159
|
+
`,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const hashPasswordFn = defineFunction('auth.hash_password', {
|
|
163
|
+
args: [arg('p_password', 'text')],
|
|
164
|
+
returns: 'text',
|
|
165
|
+
language: 'plpgsql',
|
|
166
|
+
security: 'definer',
|
|
167
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
168
|
+
body: sql`
|
|
169
|
+
BEGIN
|
|
170
|
+
RETURN crypt(p_password, gen_salt('bf', 10));
|
|
171
|
+
END;
|
|
172
|
+
`,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
export const issueRefreshTokenFn = defineFunction('auth.issue_refresh_token', {
|
|
176
|
+
args: [arg('p_user_id', 'uuid'), arg('p_family_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_repository_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_ttl', 'interval', { default: sql`'30 days'::interval` })],
|
|
177
|
+
returns: 'jsonb',
|
|
178
|
+
language: 'plpgsql',
|
|
179
|
+
security: 'definer',
|
|
180
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
181
|
+
body: sql`
|
|
182
|
+
DECLARE
|
|
183
|
+
v_raw_token TEXT;
|
|
184
|
+
v_token_hash TEXT;
|
|
185
|
+
v_family_id UUID;
|
|
186
|
+
v_expires_at TIMESTAMPTZ;
|
|
187
|
+
v_family_count INTEGER;
|
|
188
|
+
v_oldest_family UUID;
|
|
189
|
+
BEGIN
|
|
190
|
+
-- Clean up expired tokens for this user (scoped by repository)
|
|
191
|
+
DELETE FROM auth.refresh_tokens
|
|
192
|
+
WHERE user_id = p_user_id
|
|
193
|
+
AND repository_id IS NOT DISTINCT FROM p_repository_id
|
|
194
|
+
AND expires_at < now();
|
|
195
|
+
|
|
196
|
+
-- Enforce per-user family ceiling (only for new families)
|
|
197
|
+
IF p_family_id IS NULL THEN
|
|
198
|
+
SELECT count(DISTINCT family_id) INTO v_family_count
|
|
199
|
+
FROM auth.refresh_tokens
|
|
200
|
+
WHERE user_id = p_user_id
|
|
201
|
+
AND repository_id IS NOT DISTINCT FROM p_repository_id
|
|
202
|
+
AND status = 'active';
|
|
203
|
+
|
|
204
|
+
IF v_family_count >= 10 THEN
|
|
205
|
+
-- Revoke the oldest family
|
|
206
|
+
SELECT family_id INTO v_oldest_family
|
|
207
|
+
FROM auth.refresh_tokens
|
|
208
|
+
WHERE user_id = p_user_id
|
|
209
|
+
AND repository_id IS NOT DISTINCT FROM p_repository_id
|
|
210
|
+
AND status = 'active'
|
|
211
|
+
GROUP BY family_id
|
|
212
|
+
ORDER BY MIN(created_at) ASC
|
|
213
|
+
LIMIT 1;
|
|
214
|
+
|
|
215
|
+
DELETE FROM auth.refresh_tokens
|
|
216
|
+
WHERE user_id = p_user_id AND family_id = v_oldest_family;
|
|
217
|
+
END IF;
|
|
218
|
+
END IF;
|
|
219
|
+
|
|
220
|
+
-- Generate 32 random bytes, base64url encoded
|
|
221
|
+
v_raw_token := rtrim(translate(
|
|
222
|
+
encode(gen_random_bytes(32), 'base64'),
|
|
223
|
+
'+/=', '-_ '
|
|
224
|
+
));
|
|
225
|
+
|
|
226
|
+
-- Store SHA-256 hash, not the raw token
|
|
227
|
+
v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex');
|
|
228
|
+
v_family_id := COALESCE(p_family_id, gen_random_uuid());
|
|
229
|
+
v_expires_at := now() + p_ttl;
|
|
230
|
+
|
|
231
|
+
INSERT INTO auth.refresh_tokens (
|
|
232
|
+
repository_id, user_id, token_hash, family_id, status,
|
|
233
|
+
ip_address, user_agent, device_id, expires_at
|
|
234
|
+
)
|
|
235
|
+
VALUES (
|
|
236
|
+
p_repository_id, p_user_id, v_token_hash, v_family_id, 'active',
|
|
237
|
+
p_ip_address, p_user_agent, p_device_id, v_expires_at
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
RETURN jsonb_build_object(
|
|
241
|
+
'token', v_raw_token,
|
|
242
|
+
'familyId', v_family_id,
|
|
243
|
+
'expiresAt', v_expires_at
|
|
244
|
+
);
|
|
245
|
+
END;
|
|
246
|
+
`,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export const jwtFn = defineFunction('auth.jwt', {
|
|
250
|
+
returns: 'jsonb',
|
|
251
|
+
language: 'sql',
|
|
252
|
+
volatility: 'stable',
|
|
253
|
+
abilities: [can('execute', { role: 'admin' }), can('execute', { role: 'anon' }), can('execute', { role: 'authenticated' })],
|
|
254
|
+
body: sql`
|
|
255
|
+
SELECT COALESCE(
|
|
256
|
+
nullif(current_setting('request.jwt.claims', true), '')::jsonb,
|
|
257
|
+
'{}'::jsonb
|
|
258
|
+
);
|
|
259
|
+
`,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export const oauthSignInFn = defineFunction('auth.oauth_sign_in', {
|
|
263
|
+
args: [arg('p_provider', 'text'), arg('p_provider_id', 'text'), arg('p_email', 'text'), arg('p_identity_data', 'jsonb', { default: sql`'{}'::jsonb` })],
|
|
264
|
+
returns: 'jsonb',
|
|
265
|
+
language: 'plpgsql',
|
|
266
|
+
security: 'definer',
|
|
267
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
268
|
+
body: sql`
|
|
269
|
+
DECLARE
|
|
270
|
+
v_identity RECORD;
|
|
271
|
+
v_user RECORD;
|
|
272
|
+
v_providers JSONB;
|
|
273
|
+
BEGIN
|
|
274
|
+
-- Look up existing identity
|
|
275
|
+
SELECT * INTO v_identity FROM auth.identities
|
|
276
|
+
WHERE provider = p_provider AND provider_id = p_provider_id;
|
|
277
|
+
|
|
278
|
+
IF FOUND THEN
|
|
279
|
+
-- Existing user, update identity data and sign in
|
|
280
|
+
SELECT * INTO v_user FROM auth.users WHERE id = v_identity.user_id AND deleted_at IS NULL;
|
|
281
|
+
IF NOT FOUND THEN
|
|
282
|
+
RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
|
|
283
|
+
END IF;
|
|
284
|
+
|
|
285
|
+
UPDATE auth.identities
|
|
286
|
+
SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
|
|
287
|
+
WHERE id = v_identity.id;
|
|
288
|
+
|
|
289
|
+
UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
|
|
290
|
+
WHERE id = v_user.id;
|
|
291
|
+
|
|
292
|
+
RETURN jsonb_build_object(
|
|
293
|
+
'sub', v_user.id,
|
|
294
|
+
'email', v_user.email,
|
|
295
|
+
'role', v_user.role,
|
|
296
|
+
'isNewUser', false
|
|
297
|
+
);
|
|
298
|
+
END IF;
|
|
299
|
+
|
|
300
|
+
-- Check if a user with this email already exists (link account)
|
|
301
|
+
SELECT * INTO v_user FROM auth.users
|
|
302
|
+
WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
|
|
303
|
+
|
|
304
|
+
IF FOUND THEN
|
|
305
|
+
-- Link the new provider to existing user
|
|
306
|
+
v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
|
|
307
|
+
IF NOT v_providers ? p_provider THEN
|
|
308
|
+
v_providers := v_providers || to_jsonb(p_provider);
|
|
309
|
+
END IF;
|
|
310
|
+
|
|
311
|
+
UPDATE auth.users
|
|
312
|
+
SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb)
|
|
313
|
+
|| jsonb_build_object('providers', v_providers),
|
|
314
|
+
last_sign_in_at = now(),
|
|
315
|
+
updated_at = now()
|
|
316
|
+
WHERE id = v_user.id;
|
|
317
|
+
|
|
318
|
+
INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
|
|
319
|
+
VALUES (v_user.id, p_provider, p_provider_id, p_identity_data, lower(trim(p_email)));
|
|
320
|
+
|
|
321
|
+
RETURN jsonb_build_object(
|
|
322
|
+
'sub', v_user.id,
|
|
323
|
+
'email', v_user.email,
|
|
324
|
+
'role', v_user.role,
|
|
325
|
+
'isNewUser', false
|
|
326
|
+
);
|
|
327
|
+
END IF;
|
|
328
|
+
|
|
329
|
+
-- New user: create auth.users + auth.identities
|
|
330
|
+
INSERT INTO auth.users (
|
|
331
|
+
email, role, aud, raw_user_meta_data, raw_app_meta_data,
|
|
332
|
+
email_confirmed_at, last_sign_in_at
|
|
333
|
+
)
|
|
334
|
+
VALUES (
|
|
335
|
+
lower(trim(p_email)), 'authenticated', 'authenticated',
|
|
336
|
+
p_identity_data,
|
|
337
|
+
jsonb_build_object('provider', p_provider, 'providers', jsonb_build_array(p_provider)),
|
|
338
|
+
now(), now()
|
|
339
|
+
)
|
|
340
|
+
RETURNING * INTO v_user;
|
|
341
|
+
|
|
342
|
+
INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
|
|
343
|
+
VALUES (v_user.id, p_provider, p_provider_id, p_identity_data, v_user.email);
|
|
344
|
+
|
|
345
|
+
RETURN jsonb_build_object(
|
|
346
|
+
'sub', v_user.id,
|
|
347
|
+
'email', v_user.email,
|
|
348
|
+
'role', v_user.role,
|
|
349
|
+
'isNewUser', true
|
|
350
|
+
);
|
|
351
|
+
END;
|
|
352
|
+
`,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
export const oauthSignInJwtFn = defineFunction('auth.oauth_sign_in_jwt', {
|
|
356
|
+
args: [arg('p_provider', 'text'), arg('p_provider_id', 'text'), arg('p_email', 'text'), arg('p_identity_data', 'jsonb', { default: sql`'{}'::jsonb` })],
|
|
357
|
+
returns: 'jsonb',
|
|
358
|
+
language: 'plpgsql',
|
|
359
|
+
security: 'definer',
|
|
360
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
361
|
+
body: sql`
|
|
362
|
+
DECLARE
|
|
363
|
+
v_payload JSONB;
|
|
364
|
+
v_claims JSONB;
|
|
365
|
+
v_token TEXT;
|
|
366
|
+
v_refresh JSONB;
|
|
367
|
+
BEGIN
|
|
368
|
+
v_payload := auth.oauth_sign_in(p_provider, p_provider_id, p_email, p_identity_data);
|
|
369
|
+
|
|
370
|
+
v_claims := v_payload || jsonb_build_object(
|
|
371
|
+
'iat', extract(epoch FROM now())::bigint,
|
|
372
|
+
'exp', (extract(epoch FROM now()) + 3600)::bigint
|
|
373
|
+
);
|
|
374
|
+
v_token := auth.sign_jwt(v_claims);
|
|
375
|
+
|
|
376
|
+
v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
|
|
377
|
+
|
|
378
|
+
RETURN jsonb_build_object(
|
|
379
|
+
'access_token', v_token,
|
|
380
|
+
'refresh_token', v_refresh->>'token',
|
|
381
|
+
'user', v_payload
|
|
382
|
+
);
|
|
383
|
+
END;
|
|
384
|
+
`,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
export const resetPasswordFn = defineFunction('auth.reset_password', {
|
|
388
|
+
args: [arg('p_token', 'text'), arg('p_new_password', 'text')],
|
|
389
|
+
returns: 'jsonb',
|
|
390
|
+
language: 'plpgsql',
|
|
391
|
+
security: 'definer',
|
|
392
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
393
|
+
abilities: [can('execute', { role: 'anon' })],
|
|
394
|
+
body: sql`
|
|
395
|
+
DECLARE
|
|
396
|
+
v_result JSONB;
|
|
397
|
+
v_user_id UUID;
|
|
398
|
+
BEGIN
|
|
399
|
+
-- Raises on an invalid/expired/consumed token (fails closed). One-time use.
|
|
400
|
+
v_result := auth.verify_one_time_token(p_token, 'recovery');
|
|
401
|
+
v_user_id := (v_result->>'userId')::uuid;
|
|
402
|
+
|
|
403
|
+
UPDATE auth.users
|
|
404
|
+
SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
|
|
405
|
+
WHERE id = v_user_id;
|
|
406
|
+
|
|
407
|
+
-- A password reset invalidates every existing session — force re-login everywhere.
|
|
408
|
+
PERFORM auth.revoke_refresh_tokens(v_user_id);
|
|
409
|
+
|
|
410
|
+
RETURN jsonb_build_object('reset', true, 'userId', v_user_id);
|
|
411
|
+
END;
|
|
412
|
+
`,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
export const revokeRefreshTokensFn = defineFunction('auth.revoke_refresh_tokens', {
|
|
416
|
+
args: [arg('p_user_id', 'uuid'), arg('p_family_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_repository_id', 'uuid', { default: sql`NULL::uuid` })],
|
|
417
|
+
returns: 'integer',
|
|
418
|
+
language: 'plpgsql',
|
|
419
|
+
security: 'definer',
|
|
420
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
421
|
+
body: sql`
|
|
422
|
+
DECLARE
|
|
423
|
+
v_count INTEGER;
|
|
424
|
+
BEGIN
|
|
425
|
+
IF p_family_id IS NOT NULL THEN
|
|
426
|
+
DELETE FROM auth.refresh_tokens
|
|
427
|
+
WHERE user_id = p_user_id AND family_id = p_family_id;
|
|
428
|
+
ELSIF p_repository_id IS NOT NULL THEN
|
|
429
|
+
DELETE FROM auth.refresh_tokens
|
|
430
|
+
WHERE user_id = p_user_id AND repository_id = p_repository_id;
|
|
431
|
+
ELSE
|
|
432
|
+
DELETE FROM auth.refresh_tokens
|
|
433
|
+
WHERE user_id = p_user_id;
|
|
434
|
+
END IF;
|
|
435
|
+
|
|
436
|
+
GET DIAGNOSTICS v_count = ROW_COUNT;
|
|
437
|
+
RETURN v_count;
|
|
438
|
+
END;
|
|
439
|
+
`,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
export const roleFn = defineFunction('auth.role', {
|
|
443
|
+
returns: 'text',
|
|
444
|
+
language: 'sql',
|
|
445
|
+
volatility: 'stable',
|
|
446
|
+
abilities: [can('execute', { role: 'admin' }), can('execute', { role: 'anon' }), can('execute', { role: 'authenticated' })],
|
|
447
|
+
body: sql`
|
|
448
|
+
SELECT COALESCE(
|
|
449
|
+
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
|
|
450
|
+
'anon'
|
|
451
|
+
);
|
|
452
|
+
`,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
export const signInFn = defineFunction('auth.sign_in', {
|
|
456
|
+
args: [arg('p_email', 'text'), arg('p_password', 'text')],
|
|
457
|
+
returns: 'jsonb',
|
|
458
|
+
language: 'plpgsql',
|
|
459
|
+
security: 'definer',
|
|
460
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
461
|
+
body: sql`
|
|
462
|
+
DECLARE
|
|
463
|
+
v_user RECORD;
|
|
464
|
+
v_valid BOOLEAN;
|
|
465
|
+
BEGIN
|
|
466
|
+
SELECT * INTO v_user FROM auth.users
|
|
467
|
+
WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
|
|
468
|
+
|
|
469
|
+
IF NOT FOUND THEN
|
|
470
|
+
-- Timing-safe: still run bcrypt to prevent user enumeration
|
|
471
|
+
PERFORM auth.verify_password(p_password,
|
|
472
|
+
'$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012345');
|
|
473
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
474
|
+
END IF;
|
|
475
|
+
|
|
476
|
+
-- Check banned
|
|
477
|
+
IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
|
|
478
|
+
RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
|
|
479
|
+
END IF;
|
|
480
|
+
|
|
481
|
+
v_valid := auth.verify_password(p_password, v_user.encrypted_password);
|
|
482
|
+
IF NOT v_valid THEN
|
|
483
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
484
|
+
END IF;
|
|
485
|
+
|
|
486
|
+
-- Update last sign in
|
|
487
|
+
UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
|
|
488
|
+
WHERE id = v_user.id;
|
|
489
|
+
|
|
490
|
+
RETURN jsonb_build_object(
|
|
491
|
+
'sub', v_user.id,
|
|
492
|
+
'email', v_user.email,
|
|
493
|
+
'role', v_user.role
|
|
494
|
+
);
|
|
495
|
+
END;
|
|
496
|
+
`,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
export const signInJwtFn = defineFunction('auth.sign_in_jwt', {
|
|
500
|
+
args: [arg('p_email', 'text'), arg('p_password', 'text')],
|
|
501
|
+
returns: 'jsonb',
|
|
502
|
+
language: 'plpgsql',
|
|
503
|
+
security: 'definer',
|
|
504
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
505
|
+
body: sql`
|
|
506
|
+
DECLARE
|
|
507
|
+
v_payload JSONB;
|
|
508
|
+
v_claims JSONB;
|
|
509
|
+
v_token TEXT;
|
|
510
|
+
v_refresh JSONB;
|
|
511
|
+
BEGIN
|
|
512
|
+
v_payload := auth.sign_in(p_email, p_password);
|
|
513
|
+
|
|
514
|
+
v_claims := v_payload || jsonb_build_object(
|
|
515
|
+
'iat', extract(epoch FROM now())::bigint,
|
|
516
|
+
'exp', (extract(epoch FROM now()) + 3600)::bigint
|
|
517
|
+
);
|
|
518
|
+
v_token := auth.sign_jwt(v_claims);
|
|
519
|
+
|
|
520
|
+
v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
|
|
521
|
+
|
|
522
|
+
RETURN jsonb_build_object(
|
|
523
|
+
'access_token', v_token,
|
|
524
|
+
'refresh_token', v_refresh->>'token',
|
|
525
|
+
'user', v_payload
|
|
526
|
+
);
|
|
527
|
+
END;
|
|
528
|
+
`,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
export const signInSessionFn = defineFunction('auth.sign_in_session', {
|
|
532
|
+
args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` })],
|
|
533
|
+
returns: 'jsonb',
|
|
534
|
+
language: 'plpgsql',
|
|
535
|
+
security: 'definer',
|
|
536
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
537
|
+
abilities: [can('execute', { role: 'anon' })],
|
|
538
|
+
body: sql`
|
|
539
|
+
DECLARE
|
|
540
|
+
v_user JSONB;
|
|
541
|
+
v_refresh JSONB;
|
|
542
|
+
BEGIN
|
|
543
|
+
-- Raises invalid_credentials (timing-safe) on a bad email/password — fails closed.
|
|
544
|
+
v_user := auth.sign_in(p_email, p_password);
|
|
545
|
+
v_refresh := auth.issue_refresh_token(
|
|
546
|
+
(v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
|
|
547
|
+
);
|
|
548
|
+
RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
|
|
549
|
+
END;
|
|
550
|
+
`,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
export const signJwtFn = defineFunction('auth.sign_jwt', {
|
|
554
|
+
args: [arg('p_payload', 'jsonb')],
|
|
555
|
+
returns: 'text',
|
|
556
|
+
language: 'plpgsql',
|
|
557
|
+
security: 'definer',
|
|
558
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
559
|
+
body: sql`
|
|
560
|
+
DECLARE
|
|
561
|
+
v_header TEXT;
|
|
562
|
+
v_payload TEXT;
|
|
563
|
+
v_signing_input TEXT;
|
|
564
|
+
v_signature TEXT;
|
|
565
|
+
v_secret TEXT;
|
|
566
|
+
BEGIN
|
|
567
|
+
v_secret := current_setting('app.jwt_secret', true);
|
|
568
|
+
IF v_secret IS NULL OR v_secret = '' THEN
|
|
569
|
+
RAISE EXCEPTION 'app.jwt_secret not set'
|
|
570
|
+
USING HINT = 'Call SET LOCAL app.jwt_secret before auth.sign_jwt';
|
|
571
|
+
END IF;
|
|
572
|
+
|
|
573
|
+
-- Base64url encode header (replace strips MIME newlines from encode())
|
|
574
|
+
v_header := rtrim(translate(
|
|
575
|
+
replace(encode(convert_to('{"alg":"HS256","typ":"JWT"}', 'utf8'), 'base64'), E'\\n', ''),
|
|
576
|
+
'+/=', '-_ '
|
|
577
|
+
));
|
|
578
|
+
|
|
579
|
+
-- Base64url encode payload
|
|
580
|
+
v_payload := rtrim(translate(
|
|
581
|
+
replace(encode(convert_to(p_payload::text, 'utf8'), 'base64'), E'\\n', ''),
|
|
582
|
+
'+/=', '-_ '
|
|
583
|
+
));
|
|
584
|
+
|
|
585
|
+
v_signing_input := v_header || '.' || v_payload;
|
|
586
|
+
|
|
587
|
+
-- HMAC-SHA256 signature via pgcrypto
|
|
588
|
+
v_signature := rtrim(translate(
|
|
589
|
+
replace(encode(hmac(v_signing_input, v_secret, 'sha256'), 'base64'), E'\\n', ''),
|
|
590
|
+
'+/=', '-_ '
|
|
591
|
+
));
|
|
592
|
+
|
|
593
|
+
RETURN v_signing_input || '.' || v_signature;
|
|
594
|
+
END;
|
|
595
|
+
`,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
export const signUpFn = defineFunction('auth.sign_up', {
|
|
599
|
+
args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
|
|
600
|
+
returns: 'jsonb',
|
|
601
|
+
language: 'plpgsql',
|
|
602
|
+
security: 'definer',
|
|
603
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
604
|
+
body: sql`
|
|
605
|
+
DECLARE
|
|
606
|
+
v_user RECORD;
|
|
607
|
+
v_encrypted TEXT;
|
|
608
|
+
BEGIN
|
|
609
|
+
-- Check if email already exists
|
|
610
|
+
SELECT id INTO v_user FROM auth.users
|
|
611
|
+
WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
|
|
612
|
+
|
|
613
|
+
IF FOUND THEN
|
|
614
|
+
RAISE EXCEPTION 'email_exists' USING ERRCODE = 'P0001';
|
|
615
|
+
END IF;
|
|
616
|
+
|
|
617
|
+
v_encrypted := auth.hash_password(p_password);
|
|
618
|
+
|
|
619
|
+
INSERT INTO auth.users (
|
|
620
|
+
email, encrypted_password, role, aud,
|
|
621
|
+
raw_user_meta_data, raw_app_meta_data
|
|
622
|
+
)
|
|
623
|
+
VALUES (
|
|
624
|
+
lower(trim(p_email)), v_encrypted, 'authenticated', 'authenticated',
|
|
625
|
+
p_user_meta_data, jsonb_build_object('provider', 'email', 'providers', jsonb_build_array('email'))
|
|
626
|
+
)
|
|
627
|
+
RETURNING * INTO v_user;
|
|
628
|
+
|
|
629
|
+
-- Create email identity
|
|
630
|
+
INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
|
|
631
|
+
VALUES (v_user.id, 'email', v_user.id::text, p_user_meta_data, v_user.email);
|
|
632
|
+
|
|
633
|
+
RETURN jsonb_build_object(
|
|
634
|
+
'sub', v_user.id,
|
|
635
|
+
'email', v_user.email,
|
|
636
|
+
'role', v_user.role
|
|
637
|
+
);
|
|
638
|
+
END;
|
|
639
|
+
`,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
export const signUpJwtFn = defineFunction('auth.sign_up_jwt', {
|
|
643
|
+
args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
|
|
644
|
+
returns: 'jsonb',
|
|
645
|
+
language: 'plpgsql',
|
|
646
|
+
security: 'definer',
|
|
647
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
648
|
+
body: sql`
|
|
649
|
+
DECLARE
|
|
650
|
+
v_payload JSONB;
|
|
651
|
+
v_claims JSONB;
|
|
652
|
+
v_token TEXT;
|
|
653
|
+
v_refresh JSONB;
|
|
654
|
+
BEGIN
|
|
655
|
+
v_payload := auth.sign_up(p_email, p_password, p_user_meta_data);
|
|
656
|
+
|
|
657
|
+
v_claims := v_payload || jsonb_build_object(
|
|
658
|
+
'iat', extract(epoch FROM now())::bigint,
|
|
659
|
+
'exp', (extract(epoch FROM now()) + 3600)::bigint
|
|
660
|
+
);
|
|
661
|
+
v_token := auth.sign_jwt(v_claims);
|
|
662
|
+
|
|
663
|
+
v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
|
|
664
|
+
|
|
665
|
+
RETURN jsonb_build_object(
|
|
666
|
+
'access_token', v_token,
|
|
667
|
+
'refresh_token', v_refresh->>'token',
|
|
668
|
+
'user', v_payload
|
|
669
|
+
);
|
|
670
|
+
END;
|
|
671
|
+
`,
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
export const signUpSessionFn = defineFunction('auth.sign_up_session', {
|
|
675
|
+
args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` }), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` })],
|
|
676
|
+
returns: 'jsonb',
|
|
677
|
+
language: 'plpgsql',
|
|
678
|
+
security: 'definer',
|
|
679
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
680
|
+
abilities: [can('execute', { role: 'anon' })],
|
|
681
|
+
body: sql`
|
|
682
|
+
DECLARE
|
|
683
|
+
v_user JSONB;
|
|
684
|
+
v_refresh JSONB;
|
|
685
|
+
BEGIN
|
|
686
|
+
-- Raises email_exists on a duplicate. Creates auth.users only; the caller creates
|
|
687
|
+
-- the public profile as the authenticated user (invoker INSERT under RLS).
|
|
688
|
+
v_user := auth.sign_up(p_email, p_password, p_user_meta_data);
|
|
689
|
+
v_refresh := auth.issue_refresh_token(
|
|
690
|
+
(v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
|
|
691
|
+
);
|
|
692
|
+
RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
|
|
693
|
+
END;
|
|
694
|
+
`,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
export const uidFn = defineFunction('auth.uid', {
|
|
698
|
+
returns: 'uuid',
|
|
699
|
+
language: 'sql',
|
|
700
|
+
volatility: 'stable',
|
|
701
|
+
abilities: [can('execute', { role: 'admin' }), can('execute', { role: 'anon' }), can('execute', { role: 'authenticated' })],
|
|
702
|
+
body: sql`
|
|
703
|
+
SELECT COALESCE(
|
|
704
|
+
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
|
|
705
|
+
NULL
|
|
706
|
+
);
|
|
707
|
+
`,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
export const validateRefreshTokenFn = defineFunction('auth.validate_refresh_token', {
|
|
711
|
+
args: [arg('p_token', 'text'), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_ttl', 'interval', { default: sql`'30 days'::interval` })],
|
|
712
|
+
returns: 'jsonb',
|
|
713
|
+
language: 'plpgsql',
|
|
714
|
+
security: 'definer',
|
|
715
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
716
|
+
abilities: [can('execute', { role: 'anon' })],
|
|
717
|
+
body: sql`
|
|
718
|
+
DECLARE
|
|
719
|
+
v_token_hash TEXT;
|
|
720
|
+
v_row RECORD;
|
|
721
|
+
v_user_deleted TIMESTAMPTZ;
|
|
722
|
+
v_new_token JSONB;
|
|
723
|
+
BEGIN
|
|
724
|
+
-- 1. Hash the input token
|
|
725
|
+
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
|
726
|
+
|
|
727
|
+
-- 2. Look up with row lock (prevents concurrent refresh races)
|
|
728
|
+
SELECT id, user_id, family_id, status, expires_at, device_id, repository_id
|
|
729
|
+
INTO v_row
|
|
730
|
+
FROM auth.refresh_tokens
|
|
731
|
+
WHERE token_hash = v_token_hash
|
|
732
|
+
FOR UPDATE;
|
|
733
|
+
|
|
734
|
+
IF NOT FOUND THEN
|
|
735
|
+
RAISE EXCEPTION 'invalid_token' USING ERRCODE = 'P0001';
|
|
736
|
+
END IF;
|
|
737
|
+
|
|
738
|
+
-- 3. Consumed token reuse = theft detected -> revoke entire family
|
|
739
|
+
-- Return error as JSONB instead of RAISE because PostgreSQL rolls back
|
|
740
|
+
-- the DELETE when an exception propagates. Returning normally lets the
|
|
741
|
+
-- family deletion commit. The caller checks for result.error.
|
|
742
|
+
IF v_row.status = 'consumed' THEN
|
|
743
|
+
DELETE FROM auth.refresh_tokens WHERE family_id = v_row.family_id;
|
|
744
|
+
RETURN jsonb_build_object('error', 'token_reused');
|
|
745
|
+
END IF;
|
|
746
|
+
|
|
747
|
+
-- 4. Check expiry
|
|
748
|
+
IF v_row.expires_at < now() THEN
|
|
749
|
+
DELETE FROM auth.refresh_tokens WHERE id = v_row.id;
|
|
750
|
+
RAISE EXCEPTION 'token_expired' USING ERRCODE = 'P0003';
|
|
751
|
+
END IF;
|
|
752
|
+
|
|
753
|
+
-- 5. Check user is not deleted
|
|
754
|
+
SELECT deleted_at INTO v_user_deleted
|
|
755
|
+
FROM auth.users
|
|
756
|
+
WHERE id = v_row.user_id;
|
|
757
|
+
|
|
758
|
+
IF v_user_deleted IS NOT NULL THEN
|
|
759
|
+
DELETE FROM auth.refresh_tokens WHERE user_id = v_row.user_id;
|
|
760
|
+
RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
|
|
761
|
+
END IF;
|
|
762
|
+
|
|
763
|
+
-- 6. Device binding: if token was issued to a specific device, verify it
|
|
764
|
+
IF v_row.device_id IS NOT NULL AND p_device_id IS NOT NULL
|
|
765
|
+
AND v_row.device_id IS DISTINCT FROM p_device_id THEN
|
|
766
|
+
DELETE FROM auth.refresh_tokens WHERE family_id = v_row.family_id;
|
|
767
|
+
RAISE EXCEPTION 'device_mismatch' USING ERRCODE = 'P0005';
|
|
768
|
+
END IF;
|
|
769
|
+
|
|
770
|
+
-- 7. Rotate: mark consumed, issue new token in same family
|
|
771
|
+
UPDATE auth.refresh_tokens
|
|
772
|
+
SET status = 'consumed'
|
|
773
|
+
WHERE id = v_row.id;
|
|
774
|
+
|
|
775
|
+
-- Inherit repository_id from the original token
|
|
776
|
+
v_new_token := auth.issue_refresh_token(
|
|
777
|
+
v_row.user_id,
|
|
778
|
+
v_row.family_id,
|
|
779
|
+
p_ip_address,
|
|
780
|
+
p_user_agent,
|
|
781
|
+
v_row.device_id,
|
|
782
|
+
v_row.repository_id,
|
|
783
|
+
p_ttl
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
RETURN v_new_token || jsonb_build_object('userId', v_row.user_id);
|
|
787
|
+
END;
|
|
788
|
+
`,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
export const verifyJwtFn = defineFunction('auth.verify_jwt', {
|
|
792
|
+
args: [arg('p_token', 'text')],
|
|
793
|
+
returns: 'jsonb',
|
|
794
|
+
language: 'plpgsql',
|
|
795
|
+
security: 'definer',
|
|
796
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
797
|
+
body: sql`
|
|
798
|
+
DECLARE
|
|
799
|
+
v_parts TEXT[];
|
|
800
|
+
v_signing_input TEXT;
|
|
801
|
+
v_expected_sig TEXT;
|
|
802
|
+
v_payload_json JSONB;
|
|
803
|
+
v_exp BIGINT;
|
|
804
|
+
v_secret TEXT;
|
|
805
|
+
BEGIN
|
|
806
|
+
v_secret := current_setting('app.jwt_secret', true);
|
|
807
|
+
IF v_secret IS NULL OR v_secret = '' THEN
|
|
808
|
+
RAISE EXCEPTION 'app.jwt_secret not set'
|
|
809
|
+
USING HINT = 'Call SET LOCAL app.jwt_secret before auth.verify_jwt';
|
|
810
|
+
END IF;
|
|
811
|
+
|
|
812
|
+
v_parts := string_to_array(p_token, '.');
|
|
813
|
+
IF array_length(v_parts, 1) != 3 THEN
|
|
814
|
+
RETURN NULL;
|
|
815
|
+
END IF;
|
|
816
|
+
|
|
817
|
+
v_signing_input := v_parts[1] || '.' || v_parts[2];
|
|
818
|
+
|
|
819
|
+
-- Recompute expected signature (replace strips MIME newlines)
|
|
820
|
+
v_expected_sig := rtrim(translate(
|
|
821
|
+
replace(encode(hmac(v_signing_input, v_secret, 'sha256'), 'base64'), E'\\n', ''),
|
|
822
|
+
'+/=', '-_ '
|
|
823
|
+
));
|
|
824
|
+
|
|
825
|
+
IF v_parts[3] != v_expected_sig THEN
|
|
826
|
+
RETURN NULL;
|
|
827
|
+
END IF;
|
|
828
|
+
|
|
829
|
+
-- Decode payload: base64url -> base64 -> bytes -> text -> jsonb
|
|
830
|
+
v_payload_json := convert_from(
|
|
831
|
+
decode(
|
|
832
|
+
translate(v_parts[2], '-_', '+/') ||
|
|
833
|
+
repeat('=', (4 - length(v_parts[2]) % 4) % 4),
|
|
834
|
+
'base64'
|
|
835
|
+
),
|
|
836
|
+
'utf8'
|
|
837
|
+
)::jsonb;
|
|
838
|
+
|
|
839
|
+
-- Check expiration
|
|
840
|
+
v_exp := (v_payload_json->>'exp')::bigint;
|
|
841
|
+
IF v_exp IS NOT NULL AND v_exp < extract(epoch FROM now())::bigint THEN
|
|
842
|
+
RETURN NULL;
|
|
843
|
+
END IF;
|
|
844
|
+
|
|
845
|
+
RETURN v_payload_json;
|
|
846
|
+
END;
|
|
847
|
+
`,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
export const verifyOneTimeTokenFn = defineFunction('auth.verify_one_time_token', {
|
|
851
|
+
args: [arg('p_token', 'text'), arg('p_token_type', 'text')],
|
|
852
|
+
returns: 'jsonb',
|
|
853
|
+
language: 'plpgsql',
|
|
854
|
+
security: 'definer',
|
|
855
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
856
|
+
body: sql`
|
|
857
|
+
DECLARE
|
|
858
|
+
v_token_hash TEXT;
|
|
859
|
+
v_row RECORD;
|
|
860
|
+
BEGIN
|
|
861
|
+
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
|
862
|
+
|
|
863
|
+
SELECT user_id, relates_to INTO v_row
|
|
864
|
+
FROM auth.one_time_tokens
|
|
865
|
+
WHERE token_hash = v_token_hash
|
|
866
|
+
AND token_type = p_token_type
|
|
867
|
+
FOR UPDATE;
|
|
868
|
+
|
|
869
|
+
IF NOT FOUND THEN
|
|
870
|
+
RAISE EXCEPTION 'invalid_token' USING ERRCODE = 'P0001';
|
|
871
|
+
END IF;
|
|
872
|
+
|
|
873
|
+
-- Delete the token (one-time use)
|
|
874
|
+
DELETE FROM auth.one_time_tokens
|
|
875
|
+
WHERE token_hash = v_token_hash AND token_type = p_token_type;
|
|
876
|
+
|
|
877
|
+
RETURN jsonb_build_object(
|
|
878
|
+
'userId', v_row.user_id,
|
|
879
|
+
'relatesTo', v_row.relates_to
|
|
880
|
+
);
|
|
881
|
+
END;
|
|
882
|
+
`,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
export const verifyPasswordFn = defineFunction('auth.verify_password', {
|
|
886
|
+
args: [arg('p_password', 'text'), arg('p_hash', 'text')],
|
|
887
|
+
returns: 'boolean',
|
|
888
|
+
language: 'plpgsql',
|
|
889
|
+
security: 'definer',
|
|
890
|
+
searchPath: ['auth', 'public', 'pg_temp'],
|
|
891
|
+
body: sql`
|
|
892
|
+
DECLARE
|
|
893
|
+
v_normalized TEXT;
|
|
894
|
+
BEGIN
|
|
895
|
+
-- pgcrypto crypt() only supports $2a$ prefix; bcryptjs produces $2b$.
|
|
896
|
+
-- $2a$ and $2b$ are byte-identical algorithms — normalize prefix for compat.
|
|
897
|
+
v_normalized := regexp_replace(p_hash, '^\\$2[by]\\$', '$2a$');
|
|
898
|
+
RETURN crypt(p_password, v_normalized) = v_normalized;
|
|
899
|
+
END;
|
|
900
|
+
`,
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// The option groups — `generateAuthSQL`'s six switches, as descriptor sets.
|
|
906
|
+
//
|
|
907
|
+
// A thunk could branch at call time; a descriptor array cannot. Rather than make `authModule`
|
|
908
|
+
// a function (which would break every consumer doing `import { authModule }`), the default
|
|
909
|
+
// module stays a static export and `defineAuthModule(opts)` is the parameterized door.
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
/** Password hashing + JWT sign/verify. The internals every other function calls. Always on. */
|
|
913
|
+
export const coreFunctions = [hashPasswordFn, verifyPasswordFn, signJwtFn, verifyJwtFn];
|
|
914
|
+
|
|
915
|
+
/** The refresh-token family (RFC 6819 rotation). Always on. */
|
|
916
|
+
export const refreshTokenFunctions = [
|
|
917
|
+
issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, cleanupRefreshTokensFn,
|
|
918
|
+
];
|
|
919
|
+
|
|
920
|
+
/** `auth.uid()` / `auth.role()` / `auth.jwt()` — called FROM RLS policies, so granted to the
|
|
921
|
+
* request roles rather than left to the owner. Default on. */
|
|
922
|
+
export const accessorFunctions = [uidFn, roleFn, jwtFn];
|
|
923
|
+
|
|
924
|
+
/** One-time token mint/verify. Default on. */
|
|
925
|
+
export const oneTimeTokenFunctions = [createOneTimeTokenFn, verifyOneTimeTokenFn];
|
|
926
|
+
|
|
927
|
+
/** Token-gated account actions. Default on. */
|
|
928
|
+
export const tokenActionFunctions = [confirmEmailFn, resetPasswordFn, changePasswordFn];
|
|
929
|
+
|
|
930
|
+
/** The anon-safe session wrappers — the only sign-in surface anon may execute. Default on. */
|
|
931
|
+
export const sessionWrapperFunctions = [signInSessionFn, signUpSessionFn];
|
|
932
|
+
|
|
933
|
+
/** The raw sign_in/sign_up/oauth minters. Default on; never granted to anon. */
|
|
934
|
+
export const defaultAuthFunctions = [signUpFn, signInFn, oauthSignInFn];
|
|
935
|
+
|
|
936
|
+
/** JWT convenience wrappers. Default OFF, matching `generateAuthSQL`. */
|
|
937
|
+
export const jwtWrapperFunctions = [signInJwtFn, signUpJwtFn, oauthSignInJwtFn];
|
|
938
|
+
|
|
939
|
+
/** Mirrors {@link AuthSQLOptions} in sql.ts — same defaults, same meanings. */
|
|
940
|
+
export interface AuthFunctionOptions {
|
|
941
|
+
accessors?: boolean;
|
|
942
|
+
oneTimeTokens?: boolean;
|
|
943
|
+
tokenActions?: boolean;
|
|
944
|
+
sessionWrappers?: boolean;
|
|
945
|
+
defaultAuthFunctions?: boolean;
|
|
946
|
+
jwtWrappers?: boolean;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The declared function set for a given option shape. Same selection `generateAuthSQL(opts)`
|
|
951
|
+
* makes, so the two lanes stay comparable object-for-object — which is what the equivalence
|
|
952
|
+
* test asserts.
|
|
953
|
+
*/
|
|
954
|
+
export function authFunctions(opts: AuthFunctionOptions = {}) {
|
|
955
|
+
return [
|
|
956
|
+
...coreFunctions,
|
|
957
|
+
...refreshTokenFunctions,
|
|
958
|
+
...(opts.accessors !== false ? accessorFunctions : []),
|
|
959
|
+
...(opts.oneTimeTokens !== false ? oneTimeTokenFunctions : []),
|
|
960
|
+
...(opts.tokenActions !== false ? tokenActionFunctions : []),
|
|
961
|
+
...(opts.sessionWrappers !== false ? sessionWrapperFunctions : []),
|
|
962
|
+
...(opts.defaultAuthFunctions !== false ? defaultAuthFunctions : []),
|
|
963
|
+
...(opts.jwtWrappers === true ? jwtWrapperFunctions : []),
|
|
964
|
+
];
|
|
965
|
+
}
|
package/src/models/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export { MfaFactor } from './mfa-factor';
|
|
|
17
17
|
export { MfaChallenge } from './mfa-challenge';
|
|
18
18
|
export { OneTimeToken } from './one-time-token';
|
|
19
19
|
|
|
20
|
-
import { defineModule } from '@everystack/model';
|
|
20
|
+
import { defineModule, type Module } from '@everystack/model';
|
|
21
21
|
import { User } from './user';
|
|
22
22
|
import { Identity } from './identity';
|
|
23
23
|
import { RefreshToken } from './refresh-token';
|
|
@@ -25,19 +25,41 @@ import { Device } from './device';
|
|
|
25
25
|
import { MfaFactor } from './mfa-factor';
|
|
26
26
|
import { MfaChallenge } from './mfa-challenge';
|
|
27
27
|
import { OneTimeToken } from './one-time-token';
|
|
28
|
-
import {
|
|
28
|
+
import { authFunctions, type AuthFunctionOptions } from './functions';
|
|
29
|
+
|
|
30
|
+
export * from './functions';
|
|
29
31
|
|
|
30
32
|
/** All auth-owned models, for the app to spread into its db:generate model list. */
|
|
31
33
|
export const authModels = [User, Identity, RefreshToken, Device, MfaFactor, MfaChallenge, OneTimeToken];
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* The auth Module — the package's complete DB slice: the seven tables
|
|
35
|
-
* `pgcrypto` extension its functions need, and the SECURITY DEFINER
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* The auth Module for a given option shape — the package's complete DB slice: the seven tables
|
|
37
|
+
* (schema + authz), the `pgcrypto` extension its functions need, and the SECURITY DEFINER
|
|
38
|
+
* functions as DECLARED derived objects.
|
|
39
|
+
*
|
|
40
|
+
* THE FUNCTIONS MOVED FROM `functions` TO `derived`, AND THAT IS THE WHOLE POINT. The `functions`
|
|
41
|
+
* thunk is read by exactly one command — `db:generate --init` — so an app that adopted brownfield
|
|
42
|
+
* installed none of them and every gate reported clean, because a reconciler can only compare
|
|
43
|
+
* what was declared. Declared as `derived`, they are what `db:reconcile` deploys and audits, they
|
|
44
|
+
* are content-hashed and dependency-ordered, and the compiler revokes PUBLIC on every one of them
|
|
45
|
+
* unconditionally.
|
|
46
|
+
*
|
|
47
|
+
* A function is not expressible as a Model, so this is not the Models growing a new power — it is
|
|
48
|
+
* the package declaring its own compute layer through the vocabulary that already existed for it.
|
|
49
|
+
*/
|
|
50
|
+
export function defineAuthModule(opts: AuthFunctionOptions = {}): Module {
|
|
51
|
+
return defineModule({
|
|
52
|
+
models: authModels,
|
|
53
|
+
extensions: ['pgcrypto'],
|
|
54
|
+
derived: authFunctions(opts),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The auth Module with default options — what an app composes when it wants auth as shipped.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately still a VALUE, not a function call. Consumers write `modules = [appModule,
|
|
62
|
+
* authModule]`; making it `authModule()` would break every one of them for the benefit of the
|
|
63
|
+
* minority who need to change the option set, and those have {@link defineAuthModule}.
|
|
38
64
|
*/
|
|
39
|
-
export const authModule =
|
|
40
|
-
models: authModels,
|
|
41
|
-
extensions: ['pgcrypto'],
|
|
42
|
-
functions: generateAuthSQL,
|
|
43
|
-
});
|
|
65
|
+
export const authModule = defineAuthModule();
|