@everystack/auth 0.1.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/src/sql.ts ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @everystack/auth/sql — Generate SQL for PostgreSQL auth functions.
3
+ *
4
+ * Creates 7 functions in the `auth` schema using pgcrypto:
5
+ * - auth.hash_password — bcrypt via crypt()/gen_salt()
6
+ * - auth.verify_password — bcrypt verification
7
+ * - auth.sign_jwt — HMAC-SHA256 JWT signing
8
+ * - auth.verify_jwt — JWT verification
9
+ * - auth.generate_refresh_token — opaque token with SHA-256 hash storage
10
+ * - auth.validate_refresh_token — one-time-use rotation
11
+ * - auth.revoke_refresh_tokens — delete all tokens for a user
12
+ *
13
+ * Usage:
14
+ * import { generateAuthSQL } from '@everystack/auth/sql';
15
+ * const sql = generateAuthSQL();
16
+ * // Write to a migration file
17
+ */
18
+
19
+ const PGCRYPTO = `CREATE EXTENSION IF NOT EXISTS pgcrypto;`;
20
+
21
+ const HASH_PASSWORD = `\
22
+ CREATE OR REPLACE FUNCTION auth.hash_password(p_password TEXT)
23
+ RETURNS TEXT
24
+ LANGUAGE plpgsql
25
+ SECURITY DEFINER
26
+ AS $$
27
+ BEGIN
28
+ RETURN crypt(p_password, gen_salt('bf', 10));
29
+ END;
30
+ $$;`;
31
+
32
+ const VERIFY_PASSWORD = `\
33
+ CREATE OR REPLACE FUNCTION auth.verify_password(p_password TEXT, p_hash TEXT)
34
+ RETURNS BOOLEAN
35
+ LANGUAGE plpgsql
36
+ SECURITY DEFINER
37
+ AS $$
38
+ DECLARE
39
+ v_normalized TEXT;
40
+ BEGIN
41
+ -- pgcrypto crypt() only supports $2a$ prefix; bcryptjs produces $2b$.
42
+ -- $2a$ and $2b$ are byte-identical algorithms — normalize prefix for compat.
43
+ v_normalized := regexp_replace(p_hash, '^\\$2[by]\\$', '$2a$');
44
+ RETURN crypt(p_password, v_normalized) = v_normalized;
45
+ END;
46
+ $$;`;
47
+
48
+ const SIGN_JWT = `\
49
+ CREATE OR REPLACE FUNCTION auth.sign_jwt(p_payload JSONB)
50
+ RETURNS TEXT
51
+ LANGUAGE plpgsql
52
+ SECURITY DEFINER
53
+ AS $$
54
+ DECLARE
55
+ v_header TEXT;
56
+ v_payload TEXT;
57
+ v_signing_input TEXT;
58
+ v_signature TEXT;
59
+ v_secret TEXT;
60
+ BEGIN
61
+ v_secret := current_setting('app.jwt_secret', true);
62
+ IF v_secret IS NULL OR v_secret = '' THEN
63
+ RAISE EXCEPTION 'app.jwt_secret not set'
64
+ USING HINT = 'Call SET LOCAL app.jwt_secret before auth.sign_jwt';
65
+ END IF;
66
+
67
+ -- Base64url encode header (replace strips MIME newlines from encode())
68
+ v_header := rtrim(translate(
69
+ replace(encode(convert_to('{"alg":"HS256","typ":"JWT"}', 'utf8'), 'base64'), E'\\n', ''),
70
+ '+/=', '-_ '
71
+ ));
72
+
73
+ -- Base64url encode payload
74
+ v_payload := rtrim(translate(
75
+ replace(encode(convert_to(p_payload::text, 'utf8'), 'base64'), E'\\n', ''),
76
+ '+/=', '-_ '
77
+ ));
78
+
79
+ v_signing_input := v_header || '.' || v_payload;
80
+
81
+ -- HMAC-SHA256 signature via pgcrypto
82
+ v_signature := rtrim(translate(
83
+ replace(encode(hmac(v_signing_input, v_secret, 'sha256'), 'base64'), E'\\n', ''),
84
+ '+/=', '-_ '
85
+ ));
86
+
87
+ RETURN v_signing_input || '.' || v_signature;
88
+ END;
89
+ $$;`;
90
+
91
+ const VERIFY_JWT = `\
92
+ CREATE OR REPLACE FUNCTION auth.verify_jwt(p_token TEXT)
93
+ RETURNS JSONB
94
+ LANGUAGE plpgsql
95
+ SECURITY DEFINER
96
+ AS $$
97
+ DECLARE
98
+ v_parts TEXT[];
99
+ v_signing_input TEXT;
100
+ v_expected_sig TEXT;
101
+ v_payload_json JSONB;
102
+ v_exp BIGINT;
103
+ v_secret TEXT;
104
+ BEGIN
105
+ v_secret := current_setting('app.jwt_secret', true);
106
+ IF v_secret IS NULL OR v_secret = '' THEN
107
+ RAISE EXCEPTION 'app.jwt_secret not set'
108
+ USING HINT = 'Call SET LOCAL app.jwt_secret before auth.verify_jwt';
109
+ END IF;
110
+
111
+ v_parts := string_to_array(p_token, '.');
112
+ IF array_length(v_parts, 1) != 3 THEN
113
+ RETURN NULL;
114
+ END IF;
115
+
116
+ v_signing_input := v_parts[1] || '.' || v_parts[2];
117
+
118
+ -- Recompute expected signature (replace strips MIME newlines)
119
+ v_expected_sig := rtrim(translate(
120
+ replace(encode(hmac(v_signing_input, v_secret, 'sha256'), 'base64'), E'\\n', ''),
121
+ '+/=', '-_ '
122
+ ));
123
+
124
+ IF v_parts[3] != v_expected_sig THEN
125
+ RETURN NULL;
126
+ END IF;
127
+
128
+ -- Decode payload: base64url -> base64 -> bytes -> text -> jsonb
129
+ v_payload_json := convert_from(
130
+ decode(
131
+ translate(v_parts[2], '-_', '+/') ||
132
+ repeat('=', (4 - length(v_parts[2]) % 4) % 4),
133
+ 'base64'
134
+ ),
135
+ 'utf8'
136
+ )::jsonb;
137
+
138
+ -- Check expiration
139
+ v_exp := (v_payload_json->>'exp')::bigint;
140
+ IF v_exp IS NOT NULL AND v_exp < extract(epoch FROM now())::bigint THEN
141
+ RETURN NULL;
142
+ END IF;
143
+
144
+ RETURN v_payload_json;
145
+ END;
146
+ $$;`;
147
+
148
+ const GENERATE_REFRESH_TOKEN = `\
149
+ CREATE OR REPLACE FUNCTION auth.generate_refresh_token(p_user_id UUID)
150
+ RETURNS TEXT
151
+ LANGUAGE plpgsql
152
+ SECURITY DEFINER
153
+ AS $$
154
+ DECLARE
155
+ v_token TEXT;
156
+ v_hash TEXT;
157
+ v_family TEXT;
158
+ BEGIN
159
+ -- Clean up expired tokens for this user
160
+ DELETE FROM auth.refresh_tokens
161
+ WHERE user_id = p_user_id AND expires_at < now();
162
+
163
+ -- Generate random token (32 bytes, base64url encoded)
164
+ v_token := rtrim(translate(
165
+ encode(gen_random_bytes(32), 'base64'),
166
+ '+/=', '-_ '
167
+ ));
168
+
169
+ -- Store SHA-256 hash, not the raw token
170
+ v_hash := encode(digest(v_token, 'sha256'), 'hex');
171
+ v_family := gen_random_uuid()::text;
172
+
173
+ INSERT INTO auth.refresh_tokens (user_id, token_hash, family_id, expires_at)
174
+ VALUES (p_user_id, v_hash, v_family, now() + interval '7 days');
175
+
176
+ RETURN v_token;
177
+ END;
178
+ $$;`;
179
+
180
+ const VALIDATE_REFRESH_TOKEN = `\
181
+ CREATE OR REPLACE FUNCTION auth.validate_refresh_token(p_token TEXT)
182
+ RETURNS JSONB
183
+ LANGUAGE plpgsql
184
+ SECURITY DEFINER
185
+ AS $$
186
+ DECLARE
187
+ v_hash TEXT;
188
+ v_existing RECORD;
189
+ v_new_token TEXT;
190
+ v_new_hash TEXT;
191
+ BEGIN
192
+ v_hash := encode(digest(p_token, 'sha256'), 'hex');
193
+
194
+ SELECT * INTO v_existing
195
+ FROM auth.refresh_tokens
196
+ WHERE token_hash = v_hash
197
+ FOR UPDATE
198
+ LIMIT 1;
199
+
200
+ IF v_existing IS NULL THEN
201
+ RAISE EXCEPTION 'invalid_token';
202
+ END IF;
203
+
204
+ IF v_existing.expires_at < now() THEN
205
+ DELETE FROM auth.refresh_tokens WHERE id = v_existing.id;
206
+ RAISE EXCEPTION 'token_expired';
207
+ END IF;
208
+
209
+ -- Delete old token (one-time use)
210
+ DELETE FROM auth.refresh_tokens WHERE id = v_existing.id;
211
+
212
+ -- Generate new token in same family
213
+ v_new_token := rtrim(translate(
214
+ encode(gen_random_bytes(32), 'base64'),
215
+ '+/=', '-_ '
216
+ ));
217
+ v_new_hash := encode(digest(v_new_token, 'sha256'), 'hex');
218
+
219
+ INSERT INTO auth.refresh_tokens (user_id, token_hash, family_id, expires_at)
220
+ VALUES (v_existing.user_id, v_new_hash, v_existing.family_id, now() + interval '7 days');
221
+
222
+ RETURN jsonb_build_object(
223
+ 'userId', v_existing.user_id,
224
+ 'token', v_new_token,
225
+ 'familyId', v_existing.family_id
226
+ );
227
+ END;
228
+ $$;`;
229
+
230
+ const REVOKE_REFRESH_TOKENS = `\
231
+ CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens(p_user_id UUID)
232
+ RETURNS VOID
233
+ LANGUAGE plpgsql
234
+ SECURITY DEFINER
235
+ AS $$
236
+ BEGIN
237
+ DELETE FROM auth.refresh_tokens WHERE user_id = p_user_id;
238
+ END;
239
+ $$;`;
240
+
241
+ export function generateAuthSQL(): string {
242
+ const parts = [
243
+ PGCRYPTO,
244
+ '',
245
+ HASH_PASSWORD,
246
+ '',
247
+ VERIFY_PASSWORD,
248
+ '',
249
+ SIGN_JWT,
250
+ '',
251
+ VERIFY_JWT,
252
+ '',
253
+ GENERATE_REFRESH_TOKEN,
254
+ '',
255
+ VALIDATE_REFRESH_TOKEN,
256
+ '',
257
+ REVOKE_REFRESH_TOKENS,
258
+ ];
259
+
260
+ return parts.join('\n').trimEnd() + '\n';
261
+ }