@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/refresh.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Database-backed refresh token rotation using auth.sessions.
3
+ *
4
+ * Tokens are random strings (not JWTs). Only SHA-256 hashes are stored.
5
+ * Each token is one-time use — on refresh, the old token is deleted and a new one created.
6
+ * Tokens are grouped by `familyId` (stored in data JSONB) for theft detection.
7
+ *
8
+ * Refresh tokens are stored as sessions with domain='auth', type='refresh'.
9
+ *
10
+ * Usage: Pass a `sessions` table reference to `createAuthHandlers()`.
11
+ * If not provided, the existing JWT-based refresh behavior is preserved.
12
+ */
13
+
14
+ import { and, eq, lt, sql } from 'drizzle-orm';
15
+
16
+ const REFRESH_TOKEN_BYTES = 32;
17
+ const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
18
+
19
+ /**
20
+ * Generate a cryptographically random token string.
21
+ * 32 random bytes encoded as base64url (URL-safe, no padding).
22
+ */
23
+ export function generateToken(): string {
24
+ const bytes = crypto.getRandomValues(new Uint8Array(REFRESH_TOKEN_BYTES));
25
+ // Manual base64url: btoa() then replace URL-unsafe chars and strip padding
26
+ const base64 = btoa(String.fromCharCode(...bytes));
27
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
+ }
29
+
30
+ /**
31
+ * SHA-256 hash a token for storage. Returns hex string.
32
+ * Uses Web Crypto API — no native dependencies.
33
+ */
34
+ export async function hashToken(token: string): Promise<string> {
35
+ const data = new TextEncoder().encode(token);
36
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
37
+ return Array.from(new Uint8Array(hashBuffer))
38
+ .map((b) => b.toString(16).padStart(2, '0'))
39
+ .join('');
40
+ }
41
+
42
+ /**
43
+ * Create a new refresh token, store its hash in the sessions table,
44
+ * and return the raw token to send to the client.
45
+ *
46
+ * @param db - Drizzle database instance
47
+ * @param table - The sessions table reference
48
+ * @param userId - The user ID to associate with this token
49
+ * @param familyId - Optional family ID (inherits on rotation, new UUID if omitted)
50
+ * @returns Raw token string and family ID
51
+ */
52
+ export async function createRefreshToken(
53
+ db: any,
54
+ table: any,
55
+ userId: string,
56
+ familyId?: string,
57
+ ): Promise<{ token: string; familyId: string }> {
58
+ // Clean up expired refresh sessions for this user (prevents accumulation)
59
+ await db.delete(table).where(
60
+ and(
61
+ eq(table.userId, userId),
62
+ eq(table.domain, 'auth'),
63
+ eq(table.type, 'refresh'),
64
+ lt(table.expiresAt, new Date()),
65
+ ),
66
+ );
67
+
68
+ const token = generateToken();
69
+ const tokenHash = await hashToken(token);
70
+ const family = familyId ?? crypto.randomUUID();
71
+ const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
72
+
73
+ await db.insert(table).values({
74
+ domain: 'auth',
75
+ type: 'refresh',
76
+ status: 'active',
77
+ userId,
78
+ tokenHash,
79
+ expiresAt,
80
+ data: { familyId: family },
81
+ });
82
+
83
+ return { token, familyId: family };
84
+ }
85
+
86
+ /**
87
+ * Validate a refresh token and rotate it.
88
+ * Deletes the old token and creates a new one in the same family.
89
+ *
90
+ * @throws {TokenError} If token is invalid, expired, or already used
91
+ * @returns User ID, new raw token, and family ID
92
+ */
93
+ export async function rotateRefreshToken(
94
+ db: any,
95
+ table: any,
96
+ rawToken: string,
97
+ ): Promise<{ userId: string; token: string; familyId: string }> {
98
+ const tokenHash = await hashToken(rawToken);
99
+
100
+ const [existing] = await db
101
+ .select()
102
+ .from(table)
103
+ .where(and(
104
+ eq(table.tokenHash, tokenHash),
105
+ eq(table.domain, 'auth'),
106
+ eq(table.type, 'refresh'),
107
+ ))
108
+ .limit(1);
109
+
110
+ if (!existing) {
111
+ throw new TokenError('invalid_token', 'Refresh token not found');
112
+ }
113
+
114
+ // Check expiry
115
+ if (new Date(existing.expiresAt) < new Date()) {
116
+ await db.delete(table).where(eq(table.id, existing.id));
117
+ throw new TokenError('token_expired', 'Refresh token has expired');
118
+ }
119
+
120
+ // Delete old token (one-time use)
121
+ await db.delete(table).where(eq(table.id, existing.id));
122
+
123
+ // Read familyId from data JSONB
124
+ const existingFamilyId = (existing.data as Record<string, unknown>)?.familyId as string | undefined;
125
+
126
+ // Issue new token in the same family
127
+ const result = await createRefreshToken(
128
+ db,
129
+ table,
130
+ existing.userId,
131
+ existingFamilyId,
132
+ );
133
+
134
+ return {
135
+ userId: existing.userId,
136
+ token: result.token,
137
+ familyId: result.familyId,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Revoke all refresh tokens for a user.
143
+ * Use on: logout everywhere, password change, account compromise.
144
+ */
145
+ export async function revokeAllRefreshTokens(
146
+ db: any,
147
+ table: any,
148
+ userId: string,
149
+ ): Promise<void> {
150
+ await db.delete(table).where(
151
+ and(
152
+ eq(table.userId, userId),
153
+ eq(table.domain, 'auth'),
154
+ eq(table.type, 'refresh'),
155
+ ),
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Revoke all refresh tokens in a specific family.
161
+ * Use when token reuse is detected (potential theft).
162
+ */
163
+ export async function revokeTokenFamily(
164
+ db: any,
165
+ table: any,
166
+ familyId: string,
167
+ ): Promise<void> {
168
+ await db.delete(table).where(
169
+ and(
170
+ eq(table.domain, 'auth'),
171
+ eq(table.type, 'refresh'),
172
+ sql`${table.data}->>'familyId' = ${familyId}`,
173
+ ),
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Delete all expired refresh sessions. Call periodically (e.g., daily cron).
179
+ */
180
+ export async function cleanupExpiredTokens(
181
+ db: any,
182
+ table: any,
183
+ ): Promise<void> {
184
+ await db.delete(table).where(
185
+ and(
186
+ eq(table.domain, 'auth'),
187
+ eq(table.type, 'refresh'),
188
+ lt(table.expiresAt, new Date()),
189
+ ),
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Error class for token operations.
195
+ */
196
+ export class TokenError extends Error {
197
+ constructor(
198
+ public readonly code: 'invalid_token' | 'token_expired',
199
+ message: string,
200
+ ) {
201
+ super(message);
202
+ this.name = 'TokenError';
203
+ }
204
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Auth response builders that optionally set HttpOnly cookies.
3
+ *
4
+ * When `cookieOpts` is truthy, Set-Cookie headers are appended to the response
5
+ * for both the access token and refresh token. Error responses are never decorated.
6
+ */
7
+
8
+ import { serializeAuthCookies, clearAuthCookies, type CookieOptions } from './cookies';
9
+
10
+ export type CookieConfig = CookieOptions | boolean;
11
+
12
+ /**
13
+ * Build a JSON auth response, optionally decorated with Set-Cookie headers.
14
+ *
15
+ * @param body - Must contain `token` and `refreshToken` fields
16
+ * @param status - HTTP status code
17
+ * @param cookieConfig - When truthy, appends Set-Cookie headers. `true` uses defaults.
18
+ */
19
+ export function authResponse(
20
+ body: { token: string; refreshToken: string; [key: string]: unknown },
21
+ status: number,
22
+ cookieConfig?: CookieConfig,
23
+ ): Response {
24
+ const headers = new Headers({ 'Content-Type': 'application/json' });
25
+
26
+ if (cookieConfig) {
27
+ const opts = cookieConfig === true ? undefined : cookieConfig;
28
+ for (const cookie of serializeAuthCookies(body.token, body.refreshToken, opts)) {
29
+ headers.append('Set-Cookie', cookie);
30
+ }
31
+ }
32
+
33
+ return new Response(JSON.stringify(body), { status, headers });
34
+ }
35
+
36
+ /**
37
+ * Build a signout response that clears auth cookies.
38
+ */
39
+ export function signoutResponse(cookieConfig?: CookieConfig): Response {
40
+ const headers = new Headers({ 'Content-Type': 'application/json' });
41
+
42
+ if (cookieConfig) {
43
+ const opts = cookieConfig === true ? undefined : cookieConfig;
44
+ for (const cookie of clearAuthCookies(opts)) {
45
+ headers.append('Set-Cookie', cookie);
46
+ }
47
+ }
48
+
49
+ return new Response(JSON.stringify({ success: true }), { status: 200, headers });
50
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Auth schema — everyschema auth.* tables
3
+ *
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
9
+ *
10
+ * @see everyschema — canonical schema definitions
11
+ */
12
+
13
+ import {
14
+ pgSchema,
15
+ text,
16
+ timestamp,
17
+ uuid,
18
+ integer,
19
+ index,
20
+ uniqueIndex,
21
+ jsonb,
22
+ } from 'drizzle-orm/pg-core';
23
+ import { sql } from 'drizzle-orm';
24
+
25
+ const authSchema = pgSchema('auth');
26
+ export { authSchema };
27
+
28
+ // =============================================================================
29
+ // SESSIONS (unified STI)
30
+ // =============================================================================
31
+
32
+ /**
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 }
40
+ *
41
+ * domain='agent' data: { agentType, scopes, context, currentTask,
42
+ * lockedResources, transport, toolCallCount, errorCount }
43
+ *
44
+ * domain='ws' data: { connectionId, channels }
45
+ */
46
+ export const sessions = authSchema.table(
47
+ 'sessions',
48
+ {
49
+ 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
90
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
91
+ },
92
+ (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),
104
+ })
105
+ );
106
+
107
+ // =============================================================================
108
+ // IDENTITIES (WHO can be granted permissions)
109
+ // =============================================================================
110
+
111
+ /**
112
+ * Abstraction layer between users and credentials. Separates WHO (identity)
113
+ * from HOW they prove it (credentials).
114
+ *
115
+ * type='user': linked to auth.users via userId
116
+ * type='external': email addresses, phone numbers, external services
117
+ */
118
+ export const identities = authSchema.table(
119
+ 'identities',
120
+ {
121
+ 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
+
133
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
134
+ },
135
+ (table) => ({
136
+ typeIdx: index('identities_type_idx').on(table.type),
137
+ 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
+ })
141
+ );
142
+
143
+ // =============================================================================
144
+ // CREDENTIALS (HOW identities prove who they are)
145
+ // =============================================================================
146
+
147
+ /**
148
+ * Links to auth.identities, not directly to users.
149
+ *
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)
156
+ */
157
+ export const credentials = authSchema.table(
158
+ 'credentials',
159
+ {
160
+ 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'),
167
+ 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
+ },
174
+ (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`),
182
+ })
183
+ );
184
+
185
+ // =============================================================================
186
+ // DEVICES
187
+ // =============================================================================
188
+
189
+ /**
190
+ * Device attestation with trust scores and revocation.
191
+ *
192
+ * Maps from everystack security_devices:
193
+ * keyId → keyId, publicKey → publicKey, counter → attestationCount,
194
+ * attestedAt → registeredAt, lastAssertionAt → lastSeenAt
195
+ */
196
+ export const devices = authSchema.table(
197
+ 'devices',
198
+ {
199
+ id: uuid('id').primaryKey().defaultRandom(),
200
+ userId: uuid('user_id').notNull(),
201
+ 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'),
207
+ name: text('name'),
208
+ model: text('model'),
209
+ osVersion: text('os_version'),
210
+ appVersion: text('app_version'),
211
+ lastAttestation: timestamp('last_attestation', { withTimezone: true }),
212
+ attestationCount: integer('attestation_count').notNull().default(0),
213
+ trustScore: integer('trust_score').notNull().default(100),
214
+ registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull(),
215
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
216
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
217
+ revokedReason: text('revoked_reason'),
218
+ },
219
+ (table) => ({
220
+ userIdx: index('devices_user_idx').on(table.userId),
221
+ deviceIdIdx: uniqueIndex('devices_device_id_idx').on(table.deviceId),
222
+ platformIdx: index('devices_platform_idx').on(table.platform),
223
+ })
224
+ );