@everystack/auth 0.1.0 → 0.2.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/schema.ts CHANGED
@@ -1,20 +1,29 @@
1
1
  /**
2
- * Auth schema — everyschema auth.* tables
2
+ * Auth schema — default tables for everystack auth.
3
3
  *
4
4
  * Tables:
5
- * sessions — Unified STI sessions (auth/work/agent/ws domains)
6
- * identities — Abstraction layer between users and credentials
7
- * credentials — How identities prove who they are (passkey, oauth, challenge, etc.)
8
- * devices — Device attestation with trust scores
5
+ * users — Supabase-compatible user table (batteries included, replaceable)
6
+ * identities — OAuth provider links (Supabase-compatible)
7
+ * refreshTokens — RFC 6819 family-based token rotation with reuse detection
8
+ * devices — Device registry (persistent ID, push tokens) + attestation
9
+ * mfaFactors — TOTP + WebAuthn multi-factor authentication
10
+ * mfaChallenges — Ephemeral MFA challenge state
11
+ * oneTimeTokens — Email confirmation, password reset, magic links
9
12
  *
10
- * @see everyschema — canonical schema definitions
13
+ * The default schema works out of the box. Apps that need a different user model
14
+ * 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.
11
18
  */
12
19
 
13
20
  import {
14
21
  pgSchema,
22
+ boolean,
15
23
  text,
16
24
  timestamp,
17
25
  uuid,
26
+ varchar,
18
27
  integer,
19
28
  index,
20
29
  uniqueIndex,
@@ -26,159 +35,121 @@ const authSchema = pgSchema('auth');
26
35
  export { authSchema };
27
36
 
28
37
  // =============================================================================
29
- // SESSIONS (unified STI)
38
+ // USERS
30
39
  // =============================================================================
31
40
 
32
41
  /**
33
- * Unified sessions table
34
- *
35
- * domain='auth' data: { biometricAt, attestationAt, authorizedBy, authorizedAt,
36
- * revokedBy, revokedReason, challenge, challengeExpiresAt, browserName,
37
- * browserOs, location, locationHint, familyId }
38
- *
39
- * domain='work' data: { goals, taskIds, summary, outcome }
42
+ * Default user table. Supabase-compatible column names for zero-friction migration.
40
43
  *
41
- * domain='agent' data: { agentType, scopes, context, currentTask,
42
- * lockedResources, transport, toolCallCount, errorCount }
43
- *
44
- * domain='ws' data: { connectionId, channels }
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.).
45
47
  */
46
- export const sessions = authSchema.table(
47
- 'sessions',
48
+ export const users = authSchema.table(
49
+ 'users',
48
50
  {
49
51
  id: uuid('id').primaryKey().defaultRandom(),
50
-
51
- // STI discriminator
52
- domain: text('domain').notNull(), // 'auth' | 'work' | 'agent' | 'ws'
53
- type: text('type'), // auth sub-types: 'device' | 'web' | 'refresh' (NULL for work/agent/ws)
54
-
55
- // Actor
56
- userId: uuid('user_id'),
57
- agentId: text('agent_id'),
58
-
59
- // Context
60
- repositoryId: uuid('repository_id'),
61
- deviceId: uuid('device_id'),
62
-
63
- // Auth: session token
64
- tokenHash: text('token_hash').unique(),
65
-
66
- // Status (shared across domains, different values per domain)
67
- status: text('status').notNull().default('active'),
68
-
69
- // Auth: lifecycle timestamps
70
- expiresAt: timestamp('expires_at', { withTimezone: true }),
71
- lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
72
- revokedAt: timestamp('revoked_at', { withTimezone: true }),
73
-
74
- // Auth: security context
75
- ipAddress: text('ip_address'),
76
- userAgent: text('user_agent'),
77
-
78
- // Work/agent: lifecycle
79
- startedAt: timestamp('started_at', { withTimezone: true }),
80
- endedAt: timestamp('ended_at', { withTimezone: true }),
81
-
82
- // Agent: counters
83
- toolCallCount: integer('tool_call_count'),
84
- errorCount: integer('error_count'),
85
-
86
- // Domain-specific data
87
- data: jsonb('data'),
88
-
89
- // Timestamps
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),
90
66
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
67
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
91
68
  },
92
69
  (table) => ({
93
- domainIdx: index('sessions_domain_idx').on(table.domain),
94
- domainUserIdx: index('sessions_domain_user_idx').on(table.domain, table.userId),
95
- domainAgentStatusIdx: index('sessions_domain_agent_status_idx').on(table.domain, table.agentId, table.status),
96
- typeIdx: index('sessions_type_idx').on(table.type),
97
- userIdx: index('sessions_user_idx').on(table.userId),
98
- deviceIdx: index('sessions_device_idx').on(table.deviceId),
99
- tokenIdx: uniqueIndex('sessions_token_idx').on(table.tokenHash),
100
- statusIdx: index('sessions_status_idx').on(table.status),
101
- expiresIdx: index('sessions_expires_idx').on(table.expiresAt),
102
- repoIdx: index('sessions_repo_idx').on(table.repositoryId),
103
- createdIdx: index('sessions_created_idx').on(table.createdAt),
70
+ emailIdx: uniqueIndex('users_email_idx').on(table.email),
71
+ instanceIdx: index('users_instance_id_idx').on(table.instanceId),
104
72
  })
105
73
  );
106
74
 
107
75
  // =============================================================================
108
- // IDENTITIES (WHO can be granted permissions)
76
+ // IDENTITIES
109
77
  // =============================================================================
110
78
 
111
79
  /**
112
- * Abstraction layer between users and credentials. Separates WHO (identity)
113
- * from HOW they prove it (credentials).
80
+ * OAuth provider links. Supabase-compatible.
114
81
  *
115
- * type='user': linked to auth.users via userId
116
- * type='external': email addresses, phone numbers, external services
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.
117
84
  */
118
85
  export const identities = authSchema.table(
119
86
  'identities',
120
87
  {
121
88
  id: uuid('id').primaryKey().defaultRandom(),
122
- type: text('type').notNull(), // user | agent | service | aws_account | organization | external
123
- userId: uuid('user_id'),
124
- externalId: text('external_id'),
125
- name: text('name').notNull(),
126
- metadata: jsonb('metadata'),
127
-
128
- // Spam protection for external identities
129
- reputationScore: integer('reputation_score').default(0),
130
- blockedAt: timestamp('blocked_at', { withTimezone: true }),
131
- blockReason: text('block_reason'),
132
-
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 }),
133
97
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
98
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
134
99
  },
135
100
  (table) => ({
136
- typeIdx: index('identities_type_idx').on(table.type),
101
+ providerUniqueIdx: uniqueIndex('identities_provider_id_provider_idx').on(
102
+ table.providerId,
103
+ table.provider
104
+ ),
137
105
  userIdx: index('identities_user_idx').on(table.userId),
138
- externalIdx: index('identities_external_idx').on(table.type, table.externalId),
139
- blockedIdx: index('identities_blocked_idx').on(table.blockedAt),
140
106
  })
141
107
  );
142
108
 
143
109
  // =============================================================================
144
- // CREDENTIALS (HOW identities prove who they are)
110
+ // REFRESH TOKENS
145
111
  // =============================================================================
146
112
 
147
113
  /**
148
- * Links to auth.identities, not directly to users.
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
149
122
  *
150
- * Type-specific fields:
151
- * - passkey/ssh_key: publicKey, fingerprint
152
- * - password: secretHash (bcrypt/argon2/scram)
153
- * - api_key: credentialId (key prefix), secretHash (argon2)
154
- * - oauth: credentialId (providerUid), name (provider), metadata ({email})
155
- * - challenge: credentialId (nonce), expiresAt (5min), revokedAt (consumed)
123
+ * PostgreSQL path uses SECURITY DEFINER functions with SELECT FOR UPDATE
124
+ * for full atomicity. JS path uses consumed-token reuse detection as mitigation.
156
125
  */
157
- export const credentials = authSchema.table(
158
- 'credentials',
126
+ export const refreshTokens = authSchema.table(
127
+ 'refresh_tokens',
159
128
  {
160
129
  id: uuid('id').primaryKey().defaultRandom(),
161
- type: text('type').notNull(), // passkey | ssh_key | password | api_key | oauth | challenge
162
- identityId: uuid('identity_id').notNull(),
163
- credentialId: text('credential_id'),
164
- name: text('name'),
165
- publicKey: text('public_key'),
166
- secretHash: text('secret_hash'),
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(),
167
141
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
168
- expiresAt: timestamp('expires_at', { withTimezone: true }),
169
- lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
170
- revokedAt: timestamp('revoked_at', { withTimezone: true }),
171
- revokedBy: uuid('revoked_by'),
172
- metadata: jsonb('metadata'),
173
142
  },
174
143
  (table) => ({
175
- typeIdx: index('credentials_type_idx').on(table.type),
176
- identityIdx: index('credentials_identity_idx').on(table.identityId),
177
- credentialIdIdx: uniqueIndex('credentials_credential_id_idx').on(table.type, table.credentialId),
178
- expiresIdx: index('credentials_expires_idx').on(table.expiresAt),
179
- activeIdx: index('credentials_active_idx')
180
- .on(table.identityId)
181
- .where(sql`${table.revokedAt} IS NULL`),
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'`),
182
153
  })
183
154
  );
184
155
 
@@ -187,38 +158,159 @@ export const credentials = authSchema.table(
187
158
  // =============================================================================
188
159
 
189
160
  /**
190
- * Device attestation with trust scores and revocation.
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
191
169
  *
192
- * Maps from everystack security_devices:
193
- * keyId → keyId, publicKey → publicKey, counter → attestationCount,
194
- * attestedAt → registeredAt, lastAssertionAt → lastSeenAt
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
195
175
  */
196
176
  export const devices = authSchema.table(
197
177
  'devices',
198
178
  {
199
179
  id: uuid('id').primaryKey().defaultRandom(),
200
- userId: uuid('user_id').notNull(),
180
+ repositoryId: uuid('repository_id'),
181
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
201
182
  deviceId: text('device_id').notNull().unique(),
202
- platform: text('platform').notNull(),
203
- attestationType: text('attestation_type').notNull(),
204
- keyId: text('key_id'),
205
- publicKey: text('public_key'),
206
- attestationData: jsonb('attestation_data'),
183
+ platform: text('platform').notNull(), // 'ios' | 'android' | 'web'
184
+ pushToken: text('push_token'),
207
185
  name: text('name'),
208
186
  model: text('model'),
209
187
  osVersion: text('os_version'),
210
188
  appVersion: text('app_version'),
211
- lastAttestation: timestamp('last_attestation', { withTimezone: true }),
212
- attestationCount: integer('attestation_count').notNull().default(0),
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'),
213
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
214
201
  registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull(),
215
202
  lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
216
203
  revokedAt: timestamp('revoked_at', { withTimezone: true }),
217
204
  revokedReason: text('revoked_reason'),
218
205
  },
219
206
  (table) => ({
207
+ repoIdx: index('devices_repo_idx').on(table.repositoryId),
220
208
  userIdx: index('devices_user_idx').on(table.userId),
209
+ repoUserIdx: index('devices_repo_user_idx').on(table.repositoryId, table.userId),
221
210
  deviceIdIdx: uniqueIndex('devices_device_id_idx').on(table.deviceId),
222
211
  platformIdx: index('devices_platform_idx').on(table.platform),
223
212
  })
224
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
+ );