@everystack/auth 0.2.6 → 0.3.1
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 -2
- 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/schema.ts +26 -297
- package/src/sql.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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>",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/schema.ts",
|
|
38
38
|
"default": "./src/schema.ts"
|
|
39
39
|
},
|
|
40
|
+
"./models": {
|
|
41
|
+
"types": "./src/models/index.ts",
|
|
42
|
+
"default": "./src/models/index.ts"
|
|
43
|
+
},
|
|
40
44
|
"./sql": {
|
|
41
45
|
"types": "./src/sql.ts",
|
|
42
46
|
"default": "./src/sql.ts"
|
|
@@ -86,7 +90,8 @@
|
|
|
86
90
|
}
|
|
87
91
|
},
|
|
88
92
|
"dependencies": {
|
|
89
|
-
"bcryptjs": "3.0.3"
|
|
93
|
+
"bcryptjs": "3.0.3",
|
|
94
|
+
"@everystack/model": "0.3.3"
|
|
90
95
|
},
|
|
91
96
|
"peerDependencies": {
|
|
92
97
|
"@everystack/api": ">=0.1.0",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.devices — unified device registry + attestation. Auth owns the registry fields
|
|
3
|
+
* (persistent deviceId, push token, platform/metadata); @everystack/security consumes
|
|
4
|
+
* the attestation fields (keyId, publicKey, trustScore …). Written on the privileged
|
|
5
|
+
* device/attestation paths, so `writtenBy: 'functions'` (ENABLE-not-FORCE); admin
|
|
6
|
+
* manages; private.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
10
|
+
import { User } from './user';
|
|
11
|
+
|
|
12
|
+
export const Device = defineModel('devices', {
|
|
13
|
+
schema: 'auth',
|
|
14
|
+
writtenBy: 'functions',
|
|
15
|
+
private: true,
|
|
16
|
+
fields: {
|
|
17
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
18
|
+
repositoryId: field.uuid().nullable(),
|
|
19
|
+
userId: field.uuid().references(() => User, { onDelete: 'set null' }),
|
|
20
|
+
deviceId: field.text().notNull().unique(),
|
|
21
|
+
platform: field.text().notNull(), // 'ios' | 'android' | 'web'
|
|
22
|
+
pushToken: field.text().nullable(),
|
|
23
|
+
name: field.text().nullable(),
|
|
24
|
+
model: field.text().nullable(),
|
|
25
|
+
osVersion: field.text().nullable(),
|
|
26
|
+
appVersion: field.text().nullable(),
|
|
27
|
+
userAgent: field.text().nullable(),
|
|
28
|
+
|
|
29
|
+
// Attestation (consumed by @everystack/security)
|
|
30
|
+
attestationType: field.text().nullable(),
|
|
31
|
+
keyId: field.text().nullable(),
|
|
32
|
+
publicKey: field.text().nullable(),
|
|
33
|
+
attestationData: field.jsonb().nullable(),
|
|
34
|
+
trustScore: field.integer().notNull().default(100),
|
|
35
|
+
attestationCount: field.integer().notNull().default(0),
|
|
36
|
+
lastAttestation: field.timestamptz().nullable(),
|
|
37
|
+
|
|
38
|
+
// Lifecycle
|
|
39
|
+
registeredAt: field.timestamptz().defaultNow().notNull(),
|
|
40
|
+
lastSeenAt: field.timestamptz().defaultNow().notNull(),
|
|
41
|
+
revokedAt: field.timestamptz().nullable(),
|
|
42
|
+
revokedReason: field.text().nullable(),
|
|
43
|
+
},
|
|
44
|
+
constraints: [
|
|
45
|
+
index('repositoryId'),
|
|
46
|
+
index('userId'),
|
|
47
|
+
index('repositoryId', 'userId'),
|
|
48
|
+
index('platform'),
|
|
49
|
+
],
|
|
50
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
51
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.identities — OAuth provider links (Supabase-compatible). One row per
|
|
3
|
+
* (provider, provider_id). Written only by the SECDEF auth functions (oauth_sign_in,
|
|
4
|
+
* sign_up), so `writtenBy: 'functions'` (ENABLE-not-FORCE); admin manages; private.
|
|
5
|
+
*
|
|
6
|
+
* Schema + authz derive from this Model; `../schema` materializes the runtime table.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
10
|
+
import { User } from './user';
|
|
11
|
+
|
|
12
|
+
export const Identity = defineModel('identities', {
|
|
13
|
+
schema: 'auth',
|
|
14
|
+
writtenBy: 'functions',
|
|
15
|
+
private: true,
|
|
16
|
+
fields: {
|
|
17
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
18
|
+
userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
|
|
19
|
+
provider: field.text().notNull(),
|
|
20
|
+
providerId: field.text().notNull(),
|
|
21
|
+
identityData: field.jsonb().nullable(),
|
|
22
|
+
email: field.text().nullable(),
|
|
23
|
+
lastSignInAt: field.timestamptz().nullable(),
|
|
24
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
25
|
+
updatedAt: field.timestamptz().defaultNow().notNull(),
|
|
26
|
+
},
|
|
27
|
+
constraints: [
|
|
28
|
+
index('providerId', 'provider').unique(),
|
|
29
|
+
index('userId'),
|
|
30
|
+
],
|
|
31
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
32
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth's package-owned models. A consuming app composes these into its own model list
|
|
3
|
+
* so `db:generate` emits the auth schema + authz alongside the app's tables:
|
|
4
|
+
* `models = [...appModels, ...authModels]`.
|
|
5
|
+
*
|
|
6
|
+
* All seven are `schema: 'auth'`, `writtenBy: 'functions'` (SECDEF-managed →
|
|
7
|
+
* ENABLE-not-FORCE), and `private` (reached through the auth handlers, never the generic
|
|
8
|
+
* API). The SECURITY DEFINER functions that write them stay package-owned SQL (sql.ts) —
|
|
9
|
+
* not Model-expressible; the Model declares the table authz, the package ships the functions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { User } from './user';
|
|
13
|
+
export { Identity } from './identity';
|
|
14
|
+
export { RefreshToken } from './refresh-token';
|
|
15
|
+
export { Device } from './device';
|
|
16
|
+
export { MfaFactor } from './mfa-factor';
|
|
17
|
+
export { MfaChallenge } from './mfa-challenge';
|
|
18
|
+
export { OneTimeToken } from './one-time-token';
|
|
19
|
+
|
|
20
|
+
import { defineModule } from '@everystack/model';
|
|
21
|
+
import { User } from './user';
|
|
22
|
+
import { Identity } from './identity';
|
|
23
|
+
import { RefreshToken } from './refresh-token';
|
|
24
|
+
import { Device } from './device';
|
|
25
|
+
import { MfaFactor } from './mfa-factor';
|
|
26
|
+
import { MfaChallenge } from './mfa-challenge';
|
|
27
|
+
import { OneTimeToken } from './one-time-token';
|
|
28
|
+
import { generateAuthSQL } from '../sql';
|
|
29
|
+
|
|
30
|
+
/** All auth-owned models, for the app to spread into its db:generate model list. */
|
|
31
|
+
export const authModels = [User, Identity, RefreshToken, Device, MfaFactor, MfaChallenge, OneTimeToken];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The auth Module — the package's complete DB slice: the seven tables (schema + authz), the
|
|
35
|
+
* `pgcrypto` extension its functions need, and the SECURITY DEFINER functions themselves
|
|
36
|
+
* (`generateAuthSQL` — hashing, JWT, refresh tokens, one-time tokens, session/token wrappers,
|
|
37
|
+
* the default sign_in/sign_up). The app composes this so `db:generate` emits auth end to end.
|
|
38
|
+
*/
|
|
39
|
+
export const authModule = defineModule({
|
|
40
|
+
models: authModels,
|
|
41
|
+
extensions: ['pgcrypto'],
|
|
42
|
+
functions: generateAuthSQL,
|
|
43
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.mfa_challenges — ephemeral MFA challenge state, verified within a timeout window.
|
|
3
|
+
* Written on the privileged MFA path (SECDEF), so `writtenBy: 'functions'`
|
|
4
|
+
* (ENABLE-not-FORCE); admin manages; private.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
8
|
+
import { MfaFactor } from './mfa-factor';
|
|
9
|
+
|
|
10
|
+
export const MfaChallenge = defineModel('mfa_challenges', {
|
|
11
|
+
schema: 'auth',
|
|
12
|
+
writtenBy: 'functions',
|
|
13
|
+
private: true,
|
|
14
|
+
fields: {
|
|
15
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
16
|
+
factorId: field.uuid().notNull().references(() => MfaFactor, { onDelete: 'cascade' }),
|
|
17
|
+
ipAddress: field.text().nullable(),
|
|
18
|
+
otpCode: field.text().nullable(),
|
|
19
|
+
webAuthnSessionData: field.jsonb().nullable(),
|
|
20
|
+
verifiedAt: field.timestamptz().nullable(),
|
|
21
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
22
|
+
},
|
|
23
|
+
constraints: [index('factorId')],
|
|
24
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
25
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.mfa_factors — TOTP + WebAuthn factors. Enrolled 'unverified', become 'verified'
|
|
3
|
+
* after a challenge. Written on the privileged MFA path (SECDEF), so
|
|
4
|
+
* `writtenBy: 'functions'` (ENABLE-not-FORCE); admin manages; private.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineModel, field, can, index, sql } from '@everystack/model';
|
|
8
|
+
import { User } from './user';
|
|
9
|
+
|
|
10
|
+
export const MfaFactor = defineModel('mfa_factors', {
|
|
11
|
+
schema: 'auth',
|
|
12
|
+
writtenBy: 'functions',
|
|
13
|
+
private: true,
|
|
14
|
+
fields: {
|
|
15
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
16
|
+
userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
|
|
17
|
+
friendlyName: field.text().nullable(),
|
|
18
|
+
factorType: field.text().notNull(), // 'totp' | 'webauthn'
|
|
19
|
+
status: field.text().notNull().default('unverified'), // 'unverified' | 'verified'
|
|
20
|
+
secret: field.text().nullable(), // encrypted TOTP secret
|
|
21
|
+
webAuthnCredential: field.jsonb().nullable(),
|
|
22
|
+
phone: field.text().nullable(),
|
|
23
|
+
lastChallengedAt: field.timestamptz().nullable(),
|
|
24
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
25
|
+
updatedAt: field.timestamptz().defaultNow().notNull(),
|
|
26
|
+
},
|
|
27
|
+
constraints: [
|
|
28
|
+
index('userId'),
|
|
29
|
+
// Partial index: a user's verified factors (predicate in deparser normal form).
|
|
30
|
+
index('userId').where(sql`status = 'verified'::text`),
|
|
31
|
+
],
|
|
32
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
33
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.one_time_tokens — email confirmation, password reset, magic links. One row per
|
|
3
|
+
* (user_id, token_type) — a new token of the same type replaces the previous. Stored as
|
|
4
|
+
* SHA-256 hashes. Minted/verified only by SECDEF functions (create/verify_one_time_token),
|
|
5
|
+
* so `writtenBy: 'functions'` (ENABLE-not-FORCE); admin manages; private.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
9
|
+
import { User } from './user';
|
|
10
|
+
|
|
11
|
+
export const OneTimeToken = defineModel('one_time_tokens', {
|
|
12
|
+
schema: 'auth',
|
|
13
|
+
writtenBy: 'functions',
|
|
14
|
+
private: true,
|
|
15
|
+
fields: {
|
|
16
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
17
|
+
userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
|
|
18
|
+
tokenType: field.text().notNull(),
|
|
19
|
+
tokenHash: field.text().notNull(),
|
|
20
|
+
relatesTo: field.text().notNull(), // email or phone
|
|
21
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
22
|
+
updatedAt: field.timestamptz().defaultNow().notNull(),
|
|
23
|
+
},
|
|
24
|
+
constraints: [
|
|
25
|
+
index('userId', 'tokenType').unique(),
|
|
26
|
+
index('tokenHash'),
|
|
27
|
+
index('relatesTo'),
|
|
28
|
+
],
|
|
29
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
30
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.refresh_tokens — RFC 6819 family-based rotation with reuse detection. Only
|
|
3
|
+
* SHA-256 hashes are stored. Every operation goes through a SECDEF function
|
|
4
|
+
* (issue/validate/revoke/cleanup_refresh_tokens) running as owner, so
|
|
5
|
+
* `writtenBy: 'functions'` (ENABLE-not-FORCE). 0015 revoked the direct authenticated
|
|
6
|
+
* grant: admin manages, the functions do the rest.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineModel, field, can, index, sql } from '@everystack/model';
|
|
10
|
+
import { User } from './user';
|
|
11
|
+
|
|
12
|
+
export const RefreshToken = defineModel('refresh_tokens', {
|
|
13
|
+
schema: 'auth',
|
|
14
|
+
writtenBy: 'functions',
|
|
15
|
+
private: true,
|
|
16
|
+
fields: {
|
|
17
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
18
|
+
repositoryId: field.uuid().nullable(),
|
|
19
|
+
userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
|
|
20
|
+
tokenHash: field.text().notNull().unique(),
|
|
21
|
+
familyId: field.uuid().notNull().defaultRandom(),
|
|
22
|
+
status: field.text().notNull().default('active'), // 'active' | 'consumed'
|
|
23
|
+
ipAddress: field.text().nullable(),
|
|
24
|
+
userAgent: field.text().nullable(),
|
|
25
|
+
deviceId: field.uuid().nullable(),
|
|
26
|
+
expiresAt: field.timestamptz().notNull(),
|
|
27
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
28
|
+
},
|
|
29
|
+
constraints: [
|
|
30
|
+
index('userId'),
|
|
31
|
+
index('repositoryId'),
|
|
32
|
+
index('repositoryId', 'userId'),
|
|
33
|
+
index('familyId'),
|
|
34
|
+
index('expiresAt'),
|
|
35
|
+
// Partial index: the active token of a family (predicate in deparser normal form).
|
|
36
|
+
index('familyId').where(sql`status = 'active'::text`),
|
|
37
|
+
],
|
|
38
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
39
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.users — the default user table (Supabase-compatible column names for
|
|
3
|
+
* zero-friction migration).
|
|
4
|
+
*
|
|
5
|
+
* The v3 Model that owns this table's schema AND its authorization. The drizzle
|
|
6
|
+
* runtime table (`../schema`) derives from it via `toDrizzleTable`, and `db:generate`
|
|
7
|
+
* reads it to emit the DDL + RLS + grants — the package, not the consuming app, owns
|
|
8
|
+
* auth.users end to end.
|
|
9
|
+
*
|
|
10
|
+
* Authz shape (matches the live 0014 migration):
|
|
11
|
+
* - `writtenBy: 'functions'` — every write goes through the SECURITY DEFINER auth
|
|
12
|
+
* functions (sign_up / sign_in / reset_password …), which run as the table owner.
|
|
13
|
+
* The owner must keep its bypass (read encrypted_password, insert users), so RLS is
|
|
14
|
+
* ENABLE-not-FORCE. `db:doctor` additionally asserts those functions exist with a
|
|
15
|
+
* pinned search_path.
|
|
16
|
+
* - `can('manage', { role: 'admin' })` — admin (operator) gets CRUD + the `users_admin`
|
|
17
|
+
* policy.
|
|
18
|
+
* - `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` — column-scoped:
|
|
19
|
+
* `authenticated` may read only id/email/role of its OWN row (the refresh path mints
|
|
20
|
+
* the next access token from them), never the password hash. Compiles to a column
|
|
21
|
+
* grant + the `users_select_self` policy, not a table grant.
|
|
22
|
+
* - `private: true` — reached through the auth handlers, never the generic data API.
|
|
23
|
+
*
|
|
24
|
+
* Apps that need a different user model replace the postgres functions (the stable API);
|
|
25
|
+
* the schema is the batteries-included default.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
29
|
+
|
|
30
|
+
export const User = defineModel('users', {
|
|
31
|
+
schema: 'auth',
|
|
32
|
+
writtenBy: 'functions',
|
|
33
|
+
private: true,
|
|
34
|
+
fields: {
|
|
35
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
36
|
+
instanceId: field.uuid().nullable(), // Supabase compat
|
|
37
|
+
aud: field.varchar(255).default('authenticated'),
|
|
38
|
+
role: field.varchar(255).default('authenticated'),
|
|
39
|
+
email: field.varchar(255).unique(),
|
|
40
|
+
encryptedPassword: field.varchar(255).nullable(),
|
|
41
|
+
emailConfirmedAt: field.timestamptz().nullable(),
|
|
42
|
+
phone: field.varchar(255).nullable(),
|
|
43
|
+
phoneConfirmedAt: field.timestamptz().nullable(),
|
|
44
|
+
rawAppMetaData: field.jsonb().nullable(),
|
|
45
|
+
rawUserMetaData: field.jsonb().nullable(),
|
|
46
|
+
lastSignInAt: field.timestamptz().nullable(),
|
|
47
|
+
bannedUntil: field.timestamptz().nullable(),
|
|
48
|
+
deletedAt: field.timestamptz().nullable(),
|
|
49
|
+
isAnonymous: field.boolean().notNull().default(false),
|
|
50
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
51
|
+
updatedAt: field.timestamptz().defaultNow().notNull(),
|
|
52
|
+
},
|
|
53
|
+
constraints: [index('instanceId')],
|
|
54
|
+
abilities: [
|
|
55
|
+
can('manage', { role: 'admin' }),
|
|
56
|
+
// Self-read, column-scoped: id/email/role of your own row, never the hash.
|
|
57
|
+
can('read', { owner: 'id', columns: ['id', 'email', 'role'] }),
|
|
58
|
+
],
|
|
59
|
+
});
|
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
|
@@ -1066,6 +1066,33 @@ export function generateSessionWrappersSQL(): string {
|
|
|
1066
1066
|
.trimEnd() + '\n';
|
|
1067
1067
|
}
|
|
1068
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
|
+
|
|
1069
1096
|
// =============================================================================
|
|
1070
1097
|
// MAIN ENTRY POINT
|
|
1071
1098
|
// =============================================================================
|
|
@@ -1118,5 +1145,10 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
|
|
|
1118
1145
|
parts.push('', generateJwtWrappersSQL());
|
|
1119
1146
|
}
|
|
1120
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
|
+
|
|
1121
1153
|
return parts.join('\n').trimEnd() + '\n';
|
|
1122
1154
|
}
|