@everystack/auth 0.2.5 → 0.3.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/LICENSE +681 -0
- package/README.md +5 -1
- package/package.json +25 -10
- package/src/index.ts +23 -6
- package/src/models/device.ts +51 -0
- package/src/models/identity.ts +32 -0
- package/src/models/index.ts +43 -0
- package/src/models/mfa-challenge.ts +25 -0
- package/src/models/mfa-factor.ts +33 -0
- package/src/models/one-time-token.ts +30 -0
- package/src/models/refresh-token.ts +39 -0
- package/src/models/user.ts +59 -0
- package/src/postgres.ts +458 -151
- package/src/schema.ts +26 -297
- package/src/sql.ts +251 -0
package/src/schema.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth schema — default tables for everystack auth.
|
|
2
|
+
* Auth schema — the default tables for everystack auth, as drizzle-orm runtime tables.
|
|
3
|
+
*
|
|
4
|
+
* DERIVED from the package-owned Models (`./models`) — each Model is the single source
|
|
5
|
+
* of its table's columns, schema, indexes, and authz; this just materializes the
|
|
6
|
+
* drizzle-orm runtime objects the auth handlers query. Edit the Models, not this file.
|
|
3
7
|
*
|
|
4
8
|
* Tables:
|
|
5
9
|
* users — Supabase-compatible user table (batteries included, replaceable)
|
|
@@ -12,305 +16,30 @@
|
|
|
12
16
|
*
|
|
13
17
|
* The default schema works out of the box. Apps that need a different user model
|
|
14
18
|
* replace the postgres functions (sign_in, sign_up, oauth_sign_in) — the function
|
|
15
|
-
* signatures are the stable API, not the schema.
|
|
16
|
-
*
|
|
17
|
-
* Column names match Supabase for zero-friction migration.
|
|
19
|
+
* signatures are the stable API, not the schema. Column names match Supabase for
|
|
20
|
+
* zero-friction migration.
|
|
18
21
|
*/
|
|
19
22
|
|
|
23
|
+
import { pgSchema } from 'drizzle-orm/pg-core';
|
|
24
|
+
import { toDrizzleTable } from '@everystack/model/drizzle';
|
|
20
25
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} from 'drizzle-orm/pg-core';
|
|
32
|
-
import { sql } from 'drizzle-orm';
|
|
33
|
-
|
|
26
|
+
User,
|
|
27
|
+
Identity,
|
|
28
|
+
RefreshToken,
|
|
29
|
+
Device,
|
|
30
|
+
MfaFactor,
|
|
31
|
+
MfaChallenge,
|
|
32
|
+
OneTimeToken,
|
|
33
|
+
} from './models/index';
|
|
34
|
+
|
|
35
|
+
/** The `auth` Postgres schema namespace. Kept for consumers that build against it directly. */
|
|
34
36
|
const authSchema = pgSchema('auth');
|
|
35
37
|
export { authSchema };
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* Apps that need a different user model can replace this table entirely —
|
|
45
|
-
* the postgres auth functions (sign_in, sign_up, oauth_sign_in) are the real API.
|
|
46
|
-
* Replace the functions, keep the primitives (hash_password, sign_jwt, etc.).
|
|
47
|
-
*/
|
|
48
|
-
export const users = authSchema.table(
|
|
49
|
-
'users',
|
|
50
|
-
{
|
|
51
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
52
|
-
instanceId: uuid('instance_id'), // Supabase compat
|
|
53
|
-
aud: varchar('aud', { length: 255 }).default('authenticated'),
|
|
54
|
-
role: varchar('role', { length: 255 }).default('authenticated'),
|
|
55
|
-
email: varchar('email', { length: 255 }).unique(),
|
|
56
|
-
encryptedPassword: varchar('encrypted_password', { length: 255 }),
|
|
57
|
-
emailConfirmedAt: timestamp('email_confirmed_at', { withTimezone: true }),
|
|
58
|
-
phone: varchar('phone', { length: 255 }),
|
|
59
|
-
phoneConfirmedAt: timestamp('phone_confirmed_at', { withTimezone: true }),
|
|
60
|
-
rawAppMetaData: jsonb('raw_app_meta_data'),
|
|
61
|
-
rawUserMetaData: jsonb('raw_user_meta_data'),
|
|
62
|
-
lastSignInAt: timestamp('last_sign_in_at', { withTimezone: true }),
|
|
63
|
-
bannedUntil: timestamp('banned_until', { withTimezone: true }),
|
|
64
|
-
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
|
65
|
-
isAnonymous: boolean('is_anonymous').notNull().default(false),
|
|
66
|
-
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
67
|
-
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
68
|
-
},
|
|
69
|
-
(table) => ({
|
|
70
|
-
emailIdx: uniqueIndex('users_email_idx').on(table.email),
|
|
71
|
-
instanceIdx: index('users_instance_id_idx').on(table.instanceId),
|
|
72
|
-
})
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
// =============================================================================
|
|
76
|
-
// IDENTITIES
|
|
77
|
-
// =============================================================================
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* OAuth provider links. Supabase-compatible.
|
|
81
|
-
*
|
|
82
|
-
* One row per (provider, provider_id) pair. A user can have multiple identities
|
|
83
|
-
* (email + google + apple). The identity_data jsonb stores provider-specific claims.
|
|
84
|
-
*/
|
|
85
|
-
export const identities = authSchema.table(
|
|
86
|
-
'identities',
|
|
87
|
-
{
|
|
88
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
89
|
-
userId: uuid('user_id')
|
|
90
|
-
.notNull()
|
|
91
|
-
.references(() => users.id, { onDelete: 'cascade' }),
|
|
92
|
-
provider: text('provider').notNull(),
|
|
93
|
-
providerId: text('provider_id').notNull(),
|
|
94
|
-
identityData: jsonb('identity_data'),
|
|
95
|
-
email: text('email'),
|
|
96
|
-
lastSignInAt: timestamp('last_sign_in_at', { withTimezone: true }),
|
|
97
|
-
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
98
|
-
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
99
|
-
},
|
|
100
|
-
(table) => ({
|
|
101
|
-
providerUniqueIdx: uniqueIndex('identities_provider_id_provider_idx').on(
|
|
102
|
-
table.providerId,
|
|
103
|
-
table.provider
|
|
104
|
-
),
|
|
105
|
-
userIdx: index('identities_user_idx').on(table.userId),
|
|
106
|
-
})
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
// =============================================================================
|
|
110
|
-
// REFRESH TOKENS
|
|
111
|
-
// =============================================================================
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Dedicated refresh token table.
|
|
115
|
-
*
|
|
116
|
-
* Security properties:
|
|
117
|
-
* - Only SHA-256 hashes stored (never plaintext)
|
|
118
|
-
* - familyId groups a rotation chain for theft detection
|
|
119
|
-
* - status='consumed' tokens are kept; replay triggers family revocation (RFC 6819)
|
|
120
|
-
* - ip_address/user_agent enable "active sessions" display
|
|
121
|
-
* - device_id enables token-to-device binding
|
|
122
|
-
*
|
|
123
|
-
* PostgreSQL path uses SECURITY DEFINER functions with SELECT FOR UPDATE
|
|
124
|
-
* for full atomicity. JS path uses consumed-token reuse detection as mitigation.
|
|
125
|
-
*/
|
|
126
|
-
export const refreshTokens = authSchema.table(
|
|
127
|
-
'refresh_tokens',
|
|
128
|
-
{
|
|
129
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
130
|
-
repositoryId: uuid('repository_id'),
|
|
131
|
-
userId: uuid('user_id')
|
|
132
|
-
.notNull()
|
|
133
|
-
.references(() => users.id, { onDelete: 'cascade' }),
|
|
134
|
-
tokenHash: text('token_hash').notNull().unique(),
|
|
135
|
-
familyId: uuid('family_id').notNull().defaultRandom(),
|
|
136
|
-
status: text('status').notNull().default('active'), // 'active' | 'consumed'
|
|
137
|
-
ipAddress: text('ip_address'),
|
|
138
|
-
userAgent: text('user_agent'),
|
|
139
|
-
deviceId: uuid('device_id'),
|
|
140
|
-
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
141
|
-
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
142
|
-
},
|
|
143
|
-
(table) => ({
|
|
144
|
-
tokenIdx: uniqueIndex('refresh_tokens_token_idx').on(table.tokenHash),
|
|
145
|
-
userIdx: index('refresh_tokens_user_idx').on(table.userId),
|
|
146
|
-
repoIdx: index('refresh_tokens_repo_idx').on(table.repositoryId),
|
|
147
|
-
repoUserIdx: index('refresh_tokens_repo_user_idx').on(table.repositoryId, table.userId),
|
|
148
|
-
familyIdx: index('refresh_tokens_family_idx').on(table.familyId),
|
|
149
|
-
expiresIdx: index('refresh_tokens_expires_idx').on(table.expiresAt),
|
|
150
|
-
activeFamilyIdx: index('refresh_tokens_active_family_idx')
|
|
151
|
-
.on(table.familyId)
|
|
152
|
-
.where(sql`${table.status} = 'active'`),
|
|
153
|
-
})
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
// =============================================================================
|
|
157
|
-
// DEVICES
|
|
158
|
-
// =============================================================================
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Unified device registry + attestation.
|
|
162
|
-
*
|
|
163
|
-
* Auth-owned fields (device registry):
|
|
164
|
-
* deviceId — persistent UUID from SecureStorage, survives reinstalls
|
|
165
|
-
* userId — nullable, set when user signs in (anonymous on first launch)
|
|
166
|
-
* pushToken — nullable, set when app enables push notifications
|
|
167
|
-
* platform — 'ios' | 'android' | 'web'
|
|
168
|
-
* name, model, osVersion, appVersion, userAgent — device metadata
|
|
169
|
-
*
|
|
170
|
-
* Security-consumed fields (attestation):
|
|
171
|
-
* attestationType — 'apple_app_attest' | 'play_integrity' | null
|
|
172
|
-
* keyId, publicKey, attestationData — hardware-backed keys
|
|
173
|
-
* trustScore, attestationCount, lastAttestation — attestation state
|
|
174
|
-
* revokedAt, revokedReason — device revocation
|
|
175
|
-
*/
|
|
176
|
-
export const devices = authSchema.table(
|
|
177
|
-
'devices',
|
|
178
|
-
{
|
|
179
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
180
|
-
repositoryId: uuid('repository_id'),
|
|
181
|
-
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
|
182
|
-
deviceId: text('device_id').notNull().unique(),
|
|
183
|
-
platform: text('platform').notNull(), // 'ios' | 'android' | 'web'
|
|
184
|
-
pushToken: text('push_token'),
|
|
185
|
-
name: text('name'),
|
|
186
|
-
model: text('model'),
|
|
187
|
-
osVersion: text('os_version'),
|
|
188
|
-
appVersion: text('app_version'),
|
|
189
|
-
userAgent: text('user_agent'),
|
|
190
|
-
|
|
191
|
-
// Attestation (consumed by @everystack/security)
|
|
192
|
-
attestationType: text('attestation_type'),
|
|
193
|
-
keyId: text('key_id'),
|
|
194
|
-
publicKey: text('public_key'),
|
|
195
|
-
attestationData: jsonb('attestation_data'),
|
|
196
|
-
trustScore: integer('trust_score').notNull().default(100),
|
|
197
|
-
attestationCount: integer('attestation_count').notNull().default(0),
|
|
198
|
-
lastAttestation: timestamp('last_attestation', { withTimezone: true }),
|
|
199
|
-
|
|
200
|
-
// Lifecycle
|
|
201
|
-
registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull(),
|
|
202
|
-
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
|
|
203
|
-
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
204
|
-
revokedReason: text('revoked_reason'),
|
|
205
|
-
},
|
|
206
|
-
(table) => ({
|
|
207
|
-
repoIdx: index('devices_repo_idx').on(table.repositoryId),
|
|
208
|
-
userIdx: index('devices_user_idx').on(table.userId),
|
|
209
|
-
repoUserIdx: index('devices_repo_user_idx').on(table.repositoryId, table.userId),
|
|
210
|
-
deviceIdIdx: uniqueIndex('devices_device_id_idx').on(table.deviceId),
|
|
211
|
-
platformIdx: index('devices_platform_idx').on(table.platform),
|
|
212
|
-
})
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
// =============================================================================
|
|
216
|
-
// MFA FACTORS
|
|
217
|
-
// =============================================================================
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Multi-factor authentication factors. TOTP + WebAuthn.
|
|
221
|
-
*
|
|
222
|
-
* Users enroll factors (e.g. Google Authenticator) which start as 'unverified'.
|
|
223
|
-
* After confirming a challenge, the factor becomes 'verified' and is required
|
|
224
|
-
* for subsequent sign-ins.
|
|
225
|
-
*
|
|
226
|
-
* MFA handlers are a follow-up — this schema establishes the tables so apps
|
|
227
|
-
* can implement their own MFA flows via postgres functions.
|
|
228
|
-
*/
|
|
229
|
-
export const mfaFactors = authSchema.table(
|
|
230
|
-
'mfa_factors',
|
|
231
|
-
{
|
|
232
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
233
|
-
userId: uuid('user_id')
|
|
234
|
-
.notNull()
|
|
235
|
-
.references(() => users.id, { onDelete: 'cascade' }),
|
|
236
|
-
friendlyName: text('friendly_name'),
|
|
237
|
-
factorType: text('factor_type').notNull(), // 'totp' | 'webauthn'
|
|
238
|
-
status: text('status').notNull().default('unverified'), // 'unverified' | 'verified'
|
|
239
|
-
secret: text('secret'), // encrypted TOTP secret
|
|
240
|
-
webAuthnCredential: jsonb('web_authn_credential'),
|
|
241
|
-
phone: text('phone'), // future: phone MFA
|
|
242
|
-
lastChallengedAt: timestamp('last_challenged_at', { withTimezone: true }),
|
|
243
|
-
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
244
|
-
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
245
|
-
},
|
|
246
|
-
(table) => ({
|
|
247
|
-
userIdx: index('mfa_factors_user_idx').on(table.userId),
|
|
248
|
-
userVerifiedIdx: index('mfa_factors_user_verified_idx')
|
|
249
|
-
.on(table.userId)
|
|
250
|
-
.where(sql`${table.status} = 'verified'`),
|
|
251
|
-
})
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
// =============================================================================
|
|
255
|
-
// MFA CHALLENGES
|
|
256
|
-
// =============================================================================
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Ephemeral MFA challenge state.
|
|
260
|
-
*
|
|
261
|
-
* Created when a user initiates MFA verification. Verified within a timeout
|
|
262
|
-
* window. Can be cleaned up periodically.
|
|
263
|
-
*/
|
|
264
|
-
export const mfaChallenges = authSchema.table(
|
|
265
|
-
'mfa_challenges',
|
|
266
|
-
{
|
|
267
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
268
|
-
factorId: uuid('factor_id')
|
|
269
|
-
.notNull()
|
|
270
|
-
.references(() => mfaFactors.id, { onDelete: 'cascade' }),
|
|
271
|
-
ipAddress: text('ip_address'),
|
|
272
|
-
otpCode: text('otp_code'),
|
|
273
|
-
webAuthnSessionData: jsonb('web_authn_session_data'),
|
|
274
|
-
verifiedAt: timestamp('verified_at', { withTimezone: true }),
|
|
275
|
-
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
276
|
-
},
|
|
277
|
-
(table) => ({
|
|
278
|
-
factorIdx: index('mfa_challenges_factor_idx').on(table.factorId),
|
|
279
|
-
})
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
// =============================================================================
|
|
283
|
-
// ONE-TIME TOKENS
|
|
284
|
-
// =============================================================================
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* One-time tokens for email confirmation, password reset, magic links.
|
|
288
|
-
*
|
|
289
|
-
* Each (user_id, token_type) pair is unique — creating a new token of the same
|
|
290
|
-
* type replaces the previous one. Tokens are stored as SHA-256 hashes.
|
|
291
|
-
*
|
|
292
|
-
* Token types: confirmation, recovery, reauthentication,
|
|
293
|
-
* email_change_new, email_change_current, phone_change
|
|
294
|
-
*/
|
|
295
|
-
export const oneTimeTokens = authSchema.table(
|
|
296
|
-
'one_time_tokens',
|
|
297
|
-
{
|
|
298
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
299
|
-
userId: uuid('user_id')
|
|
300
|
-
.notNull()
|
|
301
|
-
.references(() => users.id, { onDelete: 'cascade' }),
|
|
302
|
-
tokenType: text('token_type').notNull(),
|
|
303
|
-
tokenHash: text('token_hash').notNull(),
|
|
304
|
-
relatesTo: text('relates_to').notNull(), // email or phone
|
|
305
|
-
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
306
|
-
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
307
|
-
},
|
|
308
|
-
(table) => ({
|
|
309
|
-
userTypeUniqueIdx: uniqueIndex('one_time_tokens_user_type_idx').on(
|
|
310
|
-
table.userId,
|
|
311
|
-
table.tokenType
|
|
312
|
-
),
|
|
313
|
-
tokenHashIdx: index('one_time_tokens_token_hash_idx').on(table.tokenHash),
|
|
314
|
-
relatesToIdx: index('one_time_tokens_relates_to_idx').on(table.relatesTo),
|
|
315
|
-
})
|
|
316
|
-
);
|
|
39
|
+
export const users = toDrizzleTable(User);
|
|
40
|
+
export const identities = toDrizzleTable(Identity);
|
|
41
|
+
export const refreshTokens = toDrizzleTable(RefreshToken);
|
|
42
|
+
export const devices = toDrizzleTable(Device);
|
|
43
|
+
export const mfaFactors = toDrizzleTable(MfaFactor);
|
|
44
|
+
export const mfaChallenges = toDrizzleTable(MfaChallenge);
|
|
45
|
+
export const oneTimeTokens = toDrizzleTable(OneTimeToken);
|
package/src/sql.ts
CHANGED
|
@@ -32,6 +32,7 @@ CREATE OR REPLACE FUNCTION auth.hash_password(p_password TEXT)
|
|
|
32
32
|
RETURNS TEXT
|
|
33
33
|
LANGUAGE plpgsql
|
|
34
34
|
SECURITY DEFINER
|
|
35
|
+
SET search_path = auth, public, pg_temp
|
|
35
36
|
AS $$
|
|
36
37
|
BEGIN
|
|
37
38
|
RETURN crypt(p_password, gen_salt('bf', 10));
|
|
@@ -43,6 +44,7 @@ CREATE OR REPLACE FUNCTION auth.verify_password(p_password TEXT, p_hash TEXT)
|
|
|
43
44
|
RETURNS BOOLEAN
|
|
44
45
|
LANGUAGE plpgsql
|
|
45
46
|
SECURITY DEFINER
|
|
47
|
+
SET search_path = auth, public, pg_temp
|
|
46
48
|
AS $$
|
|
47
49
|
DECLARE
|
|
48
50
|
v_normalized TEXT;
|
|
@@ -59,6 +61,7 @@ CREATE OR REPLACE FUNCTION auth.sign_jwt(p_payload JSONB)
|
|
|
59
61
|
RETURNS TEXT
|
|
60
62
|
LANGUAGE plpgsql
|
|
61
63
|
SECURITY DEFINER
|
|
64
|
+
SET search_path = auth, public, pg_temp
|
|
62
65
|
AS $$
|
|
63
66
|
DECLARE
|
|
64
67
|
v_header TEXT;
|
|
@@ -102,6 +105,7 @@ CREATE OR REPLACE FUNCTION auth.verify_jwt(p_token TEXT)
|
|
|
102
105
|
RETURNS JSONB
|
|
103
106
|
LANGUAGE plpgsql
|
|
104
107
|
SECURITY DEFINER
|
|
108
|
+
SET search_path = auth, public, pg_temp
|
|
105
109
|
AS $$
|
|
106
110
|
DECLARE
|
|
107
111
|
v_parts TEXT[];
|
|
@@ -499,6 +503,118 @@ $$;
|
|
|
499
503
|
|
|
500
504
|
REVOKE EXECUTE ON FUNCTION auth.verify_one_time_token FROM PUBLIC;`;
|
|
501
505
|
|
|
506
|
+
// =============================================================================
|
|
507
|
+
// TOKEN-GATED ACTION WRAPPERS
|
|
508
|
+
//
|
|
509
|
+
// These are the anon-safe entry points for the password-reset and email-confirm
|
|
510
|
+
// flows. They are SECURITY DEFINER (they update auth.users — a locked table), but the
|
|
511
|
+
// CALLER must already hold a valid one-time token, so they are credential-gated the
|
|
512
|
+
// same way auth.sign_in is password-gated: there is nothing to abuse without the token.
|
|
513
|
+
// The token-MINTING function (auth.create_one_time_token) is never granted to anon and
|
|
514
|
+
// runs only on the privileged worker path. These wrappers touch ONLY auth.users — auth
|
|
515
|
+
// columns. Public profile data lives in its own table and is never mixed in here.
|
|
516
|
+
// =============================================================================
|
|
517
|
+
|
|
518
|
+
const CONFIRM_EMAIL = `\
|
|
519
|
+
CREATE OR REPLACE FUNCTION auth.confirm_email(p_token TEXT)
|
|
520
|
+
RETURNS JSONB
|
|
521
|
+
LANGUAGE plpgsql
|
|
522
|
+
SECURITY DEFINER
|
|
523
|
+
SET search_path = auth, public, pg_temp
|
|
524
|
+
AS $$
|
|
525
|
+
DECLARE
|
|
526
|
+
v_result JSONB;
|
|
527
|
+
v_user_id UUID;
|
|
528
|
+
BEGIN
|
|
529
|
+
-- Raises on an invalid/expired/consumed token (fails closed). One-time use.
|
|
530
|
+
v_result := auth.verify_one_time_token(p_token, 'confirmation');
|
|
531
|
+
v_user_id := (v_result->>'userId')::uuid;
|
|
532
|
+
|
|
533
|
+
UPDATE auth.users
|
|
534
|
+
SET email_confirmed_at = now(), updated_at = now()
|
|
535
|
+
WHERE id = v_user_id;
|
|
536
|
+
|
|
537
|
+
RETURN jsonb_build_object('confirmed', true, 'userId', v_user_id);
|
|
538
|
+
END;
|
|
539
|
+
$$;
|
|
540
|
+
|
|
541
|
+
REVOKE EXECUTE ON FUNCTION auth.confirm_email FROM PUBLIC;`;
|
|
542
|
+
|
|
543
|
+
const RESET_PASSWORD = `\
|
|
544
|
+
CREATE OR REPLACE FUNCTION auth.reset_password(p_token TEXT, p_new_password TEXT)
|
|
545
|
+
RETURNS JSONB
|
|
546
|
+
LANGUAGE plpgsql
|
|
547
|
+
SECURITY DEFINER
|
|
548
|
+
SET search_path = auth, public, pg_temp
|
|
549
|
+
AS $$
|
|
550
|
+
DECLARE
|
|
551
|
+
v_result JSONB;
|
|
552
|
+
v_user_id UUID;
|
|
553
|
+
BEGIN
|
|
554
|
+
-- Raises on an invalid/expired/consumed token (fails closed). One-time use.
|
|
555
|
+
v_result := auth.verify_one_time_token(p_token, 'recovery');
|
|
556
|
+
v_user_id := (v_result->>'userId')::uuid;
|
|
557
|
+
|
|
558
|
+
UPDATE auth.users
|
|
559
|
+
SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
|
|
560
|
+
WHERE id = v_user_id;
|
|
561
|
+
|
|
562
|
+
-- A password reset invalidates every existing session — force re-login everywhere.
|
|
563
|
+
PERFORM auth.revoke_refresh_tokens(v_user_id);
|
|
564
|
+
|
|
565
|
+
RETURN jsonb_build_object('reset', true, 'userId', v_user_id);
|
|
566
|
+
END;
|
|
567
|
+
$$;
|
|
568
|
+
|
|
569
|
+
REVOKE EXECUTE ON FUNCTION auth.reset_password FROM PUBLIC;`;
|
|
570
|
+
|
|
571
|
+
// auth.change_password — the authenticated self-service password change. Not token-gated
|
|
572
|
+
// but credential-gated: the caller must be authenticated (auth.uid() from the verified
|
|
573
|
+
// JWT) AND prove the current password before the new one is set. SECURITY DEFINER because
|
|
574
|
+
// verifying the current password requires reading encrypted_password, which authenticated
|
|
575
|
+
// must never be granted. Granted to authenticated (not anon); called via withRole.
|
|
576
|
+
const CHANGE_PASSWORD = `\
|
|
577
|
+
CREATE OR REPLACE FUNCTION auth.change_password(p_current_password TEXT, p_new_password TEXT)
|
|
578
|
+
RETURNS JSONB
|
|
579
|
+
LANGUAGE plpgsql
|
|
580
|
+
SECURITY DEFINER
|
|
581
|
+
SET search_path = auth, public, pg_temp
|
|
582
|
+
AS $$
|
|
583
|
+
DECLARE
|
|
584
|
+
v_user_id UUID;
|
|
585
|
+
v_hash TEXT;
|
|
586
|
+
BEGIN
|
|
587
|
+
-- The caller is authenticated; their verified subject claim identifies them.
|
|
588
|
+
-- Read the claim inline so this wrapper has no dependency on the accessor functions.
|
|
589
|
+
v_user_id := (nullif(current_setting('request.jwt.claims', true), '')::jsonb->>'sub')::uuid;
|
|
590
|
+
IF v_user_id IS NULL THEN
|
|
591
|
+
RAISE EXCEPTION 'not_authenticated' USING ERRCODE = 'P0001';
|
|
592
|
+
END IF;
|
|
593
|
+
|
|
594
|
+
SELECT encrypted_password INTO v_hash FROM auth.users
|
|
595
|
+
WHERE id = v_user_id AND deleted_at IS NULL;
|
|
596
|
+
IF NOT FOUND OR v_hash IS NULL THEN
|
|
597
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
598
|
+
END IF;
|
|
599
|
+
|
|
600
|
+
-- Verify the current password before changing it (the gate).
|
|
601
|
+
IF NOT auth.verify_password(p_current_password, v_hash) THEN
|
|
602
|
+
RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
|
|
603
|
+
END IF;
|
|
604
|
+
|
|
605
|
+
UPDATE auth.users
|
|
606
|
+
SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
|
|
607
|
+
WHERE id = v_user_id;
|
|
608
|
+
|
|
609
|
+
-- A password change invalidates every existing session — force re-login everywhere.
|
|
610
|
+
PERFORM auth.revoke_refresh_tokens(v_user_id);
|
|
611
|
+
|
|
612
|
+
RETURN jsonb_build_object('changed', true, 'userId', v_user_id);
|
|
613
|
+
END;
|
|
614
|
+
$$;
|
|
615
|
+
|
|
616
|
+
REVOKE EXECUTE ON FUNCTION auth.change_password FROM PUBLIC;`;
|
|
617
|
+
|
|
502
618
|
// =============================================================================
|
|
503
619
|
// DEFAULT AUTH FUNCTIONS (work against default auth.users schema)
|
|
504
620
|
// =============================================================================
|
|
@@ -811,6 +927,76 @@ $$;
|
|
|
811
927
|
|
|
812
928
|
REVOKE EXECUTE ON FUNCTION auth.oauth_sign_in_jwt FROM PUBLIC;`;
|
|
813
929
|
|
|
930
|
+
// =============================================================================
|
|
931
|
+
// SESSION WRAPPERS — the anon-safe login/signup entry points for a least-priv API
|
|
932
|
+
//
|
|
933
|
+
// Each is credential-gated (sign_in raises on a bad password; sign_up raises on a dup
|
|
934
|
+
// email), so the caller proves the credential before anything is minted. They verify or
|
|
935
|
+
// create the user AND issue the refresh token internally (as the definer/owner), so the
|
|
936
|
+
// raw minters — issue_refresh_token, sign_jwt — never need a grant to anon and stay
|
|
937
|
+
// REVOKE'd. They return only the caller's OWN identity plus the refresh token; the ACCESS
|
|
938
|
+
// JWT is minted by the handler in JS (so app-level enrichClaims, e.g. the `aud` claim,
|
|
939
|
+
// is preserved). They touch only auth.users; the public profile is created by a trigger.
|
|
940
|
+
// =============================================================================
|
|
941
|
+
|
|
942
|
+
const SIGN_IN_SESSION = `\
|
|
943
|
+
CREATE OR REPLACE FUNCTION auth.sign_in_session(
|
|
944
|
+
p_email TEXT,
|
|
945
|
+
p_password TEXT,
|
|
946
|
+
p_ip_address TEXT DEFAULT NULL,
|
|
947
|
+
p_user_agent TEXT DEFAULT NULL,
|
|
948
|
+
p_device_id UUID DEFAULT NULL
|
|
949
|
+
)
|
|
950
|
+
RETURNS JSONB
|
|
951
|
+
LANGUAGE plpgsql
|
|
952
|
+
SECURITY DEFINER
|
|
953
|
+
SET search_path = auth, public, pg_temp
|
|
954
|
+
AS $$
|
|
955
|
+
DECLARE
|
|
956
|
+
v_user JSONB;
|
|
957
|
+
v_refresh JSONB;
|
|
958
|
+
BEGIN
|
|
959
|
+
-- Raises invalid_credentials (timing-safe) on a bad email/password — fails closed.
|
|
960
|
+
v_user := auth.sign_in(p_email, p_password);
|
|
961
|
+
v_refresh := auth.issue_refresh_token(
|
|
962
|
+
(v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
|
|
963
|
+
);
|
|
964
|
+
RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
|
|
965
|
+
END;
|
|
966
|
+
$$;
|
|
967
|
+
|
|
968
|
+
REVOKE EXECUTE ON FUNCTION auth.sign_in_session FROM PUBLIC;`;
|
|
969
|
+
|
|
970
|
+
const SIGN_UP_SESSION = `\
|
|
971
|
+
CREATE OR REPLACE FUNCTION auth.sign_up_session(
|
|
972
|
+
p_email TEXT,
|
|
973
|
+
p_password TEXT,
|
|
974
|
+
p_user_meta_data JSONB DEFAULT '{}',
|
|
975
|
+
p_ip_address TEXT DEFAULT NULL,
|
|
976
|
+
p_user_agent TEXT DEFAULT NULL,
|
|
977
|
+
p_device_id UUID DEFAULT NULL
|
|
978
|
+
)
|
|
979
|
+
RETURNS JSONB
|
|
980
|
+
LANGUAGE plpgsql
|
|
981
|
+
SECURITY DEFINER
|
|
982
|
+
SET search_path = auth, public, pg_temp
|
|
983
|
+
AS $$
|
|
984
|
+
DECLARE
|
|
985
|
+
v_user JSONB;
|
|
986
|
+
v_refresh JSONB;
|
|
987
|
+
BEGIN
|
|
988
|
+
-- Raises email_exists on a duplicate. Creates auth.users only; the caller creates
|
|
989
|
+
-- the public profile as the authenticated user (invoker INSERT under RLS).
|
|
990
|
+
v_user := auth.sign_up(p_email, p_password, p_user_meta_data);
|
|
991
|
+
v_refresh := auth.issue_refresh_token(
|
|
992
|
+
(v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
|
|
993
|
+
);
|
|
994
|
+
RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
|
|
995
|
+
END;
|
|
996
|
+
$$;
|
|
997
|
+
|
|
998
|
+
REVOKE EXECUTE ON FUNCTION auth.sign_up_session FROM PUBLIC;`;
|
|
999
|
+
|
|
814
1000
|
// =============================================================================
|
|
815
1001
|
// COMPOSABLE GENERATORS
|
|
816
1002
|
// =============================================================================
|
|
@@ -843,6 +1029,17 @@ export function generateOneTimeTokenSQL(): string {
|
|
|
843
1029
|
.trimEnd() + '\n';
|
|
844
1030
|
}
|
|
845
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* auth.confirm_email, auth.reset_password — token-gated action wrappers. The anon-safe
|
|
1034
|
+
* entry points for the email-confirm and password-reset flows (the caller holds a valid
|
|
1035
|
+
* one-time token; the token-minting function stays owner-only on the worker path).
|
|
1036
|
+
*/
|
|
1037
|
+
export function generateTokenActionsSQL(): string {
|
|
1038
|
+
return [CONFIRM_EMAIL, '', RESET_PASSWORD, '', CHANGE_PASSWORD]
|
|
1039
|
+
.join('\n')
|
|
1040
|
+
.trimEnd() + '\n';
|
|
1041
|
+
}
|
|
1042
|
+
|
|
846
1043
|
/** auth.sign_in, auth.sign_up, auth.oauth_sign_in (work against default schema) */
|
|
847
1044
|
export function generateDefaultAuthSQL(): string {
|
|
848
1045
|
return [SIGN_UP, '', SIGN_IN, '', OAUTH_SIGN_IN]
|
|
@@ -857,6 +1054,45 @@ export function generateJwtWrappersSQL(): string {
|
|
|
857
1054
|
.trimEnd() + '\n';
|
|
858
1055
|
}
|
|
859
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* auth.sign_in_session, auth.sign_up_session — the anon-safe, credential-gated login/signup
|
|
1059
|
+
* entry points for a least-privilege API. They verify/create the user and issue the refresh
|
|
1060
|
+
* token internally (the raw minters stay owner-only); the handler mints the access JWT in JS
|
|
1061
|
+
* so enrichClaims is preserved.
|
|
1062
|
+
*/
|
|
1063
|
+
export function generateSessionWrappersSQL(): string {
|
|
1064
|
+
return [SIGN_IN_SESSION, '', SIGN_UP_SESSION]
|
|
1065
|
+
.join('\n')
|
|
1066
|
+
.trimEnd() + '\n';
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The role grants the generated functions need to be reachable — schema USAGE plus EXECUTE on
|
|
1071
|
+
* the anon-/authenticated-callable entry points. Kept SEPARATE from the function generators
|
|
1072
|
+
* (which stay role-agnostic and REVOKE EXECUTE FROM PUBLIC) so the package's complete migration
|
|
1073
|
+
* is self-sufficient — `db:generate --init` produces a WORKING auth, not one a consumer must
|
|
1074
|
+
* hand-grant. The raw minters (sign_in, issue_refresh_token, …) are deliberately NOT here: only
|
|
1075
|
+
* the credential-/token-gated wrappers are exposed. Grants the everystack role chain
|
|
1076
|
+
* (anon/authenticated/admin, created by db:provision); emitted after the functions exist.
|
|
1077
|
+
*
|
|
1078
|
+
* `opts` mirrors {@link generateAuthSQL} so only grants for included functions are emitted.
|
|
1079
|
+
*/
|
|
1080
|
+
export function generateAuthGrantsSQL(opts: AuthSQLOptions = {}): string {
|
|
1081
|
+
const lines = ['GRANT USAGE ON SCHEMA auth TO anon, authenticated, admin;'];
|
|
1082
|
+
// validate_refresh_token is always generated (the refresh path) and anon-callable.
|
|
1083
|
+
lines.push('GRANT EXECUTE ON FUNCTION auth.validate_refresh_token(text, uuid, text, text, interval) TO anon;');
|
|
1084
|
+
if (opts.tokenActions !== false) {
|
|
1085
|
+
lines.push('GRANT EXECUTE ON FUNCTION auth.confirm_email(text) TO anon;');
|
|
1086
|
+
lines.push('GRANT EXECUTE ON FUNCTION auth.reset_password(text, text) TO anon;');
|
|
1087
|
+
lines.push('GRANT EXECUTE ON FUNCTION auth.change_password(text, text) TO authenticated;');
|
|
1088
|
+
}
|
|
1089
|
+
if (opts.sessionWrappers !== false) {
|
|
1090
|
+
lines.push('GRANT EXECUTE ON FUNCTION auth.sign_in_session(text, text, text, text, uuid) TO anon;');
|
|
1091
|
+
lines.push('GRANT EXECUTE ON FUNCTION auth.sign_up_session(text, text, jsonb, text, text, uuid) TO anon;');
|
|
1092
|
+
}
|
|
1093
|
+
return lines.join('\n') + '\n';
|
|
1094
|
+
}
|
|
1095
|
+
|
|
860
1096
|
// =============================================================================
|
|
861
1097
|
// MAIN ENTRY POINT
|
|
862
1098
|
// =============================================================================
|
|
@@ -866,6 +1102,10 @@ export interface AuthSQLOptions {
|
|
|
866
1102
|
accessors?: boolean;
|
|
867
1103
|
/** Include one-time token functions (default: true) */
|
|
868
1104
|
oneTimeTokens?: boolean;
|
|
1105
|
+
/** Include the token-gated action wrappers confirm_email/reset_password (default: true) */
|
|
1106
|
+
tokenActions?: boolean;
|
|
1107
|
+
/** Include the anon-safe session wrappers sign_in_session/sign_up_session (default: true) */
|
|
1108
|
+
sessionWrappers?: boolean;
|
|
869
1109
|
/** Include default sign_in/sign_up/oauth_sign_in functions (default: true) */
|
|
870
1110
|
defaultAuthFunctions?: boolean;
|
|
871
1111
|
/** Include JWT convenience wrappers (default: false) */
|
|
@@ -892,6 +1132,12 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
|
|
|
892
1132
|
if (options?.oneTimeTokens !== false) {
|
|
893
1133
|
parts.push('', generateOneTimeTokenSQL());
|
|
894
1134
|
}
|
|
1135
|
+
if (options?.tokenActions !== false) {
|
|
1136
|
+
parts.push('', generateTokenActionsSQL());
|
|
1137
|
+
}
|
|
1138
|
+
if (options?.sessionWrappers !== false) {
|
|
1139
|
+
parts.push('', generateSessionWrappersSQL());
|
|
1140
|
+
}
|
|
895
1141
|
if (options?.defaultAuthFunctions !== false) {
|
|
896
1142
|
parts.push('', generateDefaultAuthSQL());
|
|
897
1143
|
}
|
|
@@ -899,5 +1145,10 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
|
|
|
899
1145
|
parts.push('', generateJwtWrappersSQL());
|
|
900
1146
|
}
|
|
901
1147
|
|
|
1148
|
+
// The role grants come LAST — every GRANT EXECUTE target now exists. This is what makes the
|
|
1149
|
+
// generated migration self-sufficient: the functions are reachable by the app role chain
|
|
1150
|
+
// without a hand-written grant migration.
|
|
1151
|
+
parts.push('', generateAuthGrantsSQL(options));
|
|
1152
|
+
|
|
902
1153
|
return parts.join('\n').trimEnd() + '\n';
|
|
903
1154
|
}
|