@everystack/auth 0.1.0 → 0.2.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/README.md +1 -7
- package/package.json +33 -22
- package/src/api.ts +42 -8
- package/src/client/index.tsx +39 -7
- package/src/cookies.ts +3 -0
- package/src/csrf.ts +57 -0
- package/src/index.ts +276 -46
- package/src/jwt/index.ts +4 -3
- package/src/passwords.ts +1 -1
- package/src/plugin.ts +264 -0
- package/src/postgres.ts +310 -59
- package/src/refresh.ts +121 -57
- package/src/schema.ts +222 -130
- package/src/sql.ts +715 -73
- package/src/testing.ts +232 -0
package/src/testing.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/auth/testing — E2E test helpers for authorization and security.
|
|
3
|
+
*
|
|
4
|
+
* Provides tools for testing multiple roles, seeding test users, and making
|
|
5
|
+
* authenticated requests against any everystack handler — local or deployed.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - createTestAuth(secret) — sign/verify JWTs for any role
|
|
9
|
+
* - seedTestUser(sql, opts) — ensure a test user exists with known credentials
|
|
10
|
+
* - createTestRoles(secret) — pre-built tokens for anon/authenticated/admin
|
|
11
|
+
*
|
|
12
|
+
* Works against any environment — local dev, CI, deployed stages.
|
|
13
|
+
* For deployed stages, retrieve secrets via: sst secret get JWT_SECRET --stage <name>
|
|
14
|
+
*
|
|
15
|
+
* @see docs/testing.md for the full testing guide
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createJwt } from './jwt';
|
|
19
|
+
import { hmacNode } from './jwt/hmac-node';
|
|
20
|
+
import { hashPassword } from './passwords';
|
|
21
|
+
|
|
22
|
+
/** Validates a SQL identifier (table/column name) to prevent injection. */
|
|
23
|
+
function assertIdentifier(name: string, label: string): void {
|
|
24
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
25
|
+
throw new Error(`${label} contains invalid characters: ${name}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── createTestAuth ──────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface TestAuthOptions {
|
|
32
|
+
/** Token lifetime in seconds. Default: 3600 (1 hour). */
|
|
33
|
+
ttlSec?: number;
|
|
34
|
+
/** Default claims merged into every sign() call. */
|
|
35
|
+
defaults?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TestAuth {
|
|
39
|
+
/** Sign a JWT with the given claims. Auto-fills iat/exp if not present. */
|
|
40
|
+
sign(claims: Record<string, unknown>): Promise<string>;
|
|
41
|
+
/** Verify a token and return its claims, or null if invalid/expired. */
|
|
42
|
+
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
43
|
+
/** Decode a token without verification. For debugging only. */
|
|
44
|
+
decode(token: string): Record<string, unknown> | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a test auth helper bound to a JWT secret.
|
|
49
|
+
*
|
|
50
|
+
* @param secret - The JWT_SECRET used by the handler under test.
|
|
51
|
+
* @param options - Optional defaults and TTL override.
|
|
52
|
+
*/
|
|
53
|
+
export function createTestAuth(secret: string, options?: TestAuthOptions): TestAuth {
|
|
54
|
+
if (!secret) throw new Error('createTestAuth: secret is required');
|
|
55
|
+
|
|
56
|
+
const jwt = createJwt({
|
|
57
|
+
secret,
|
|
58
|
+
hmac: hmacNode,
|
|
59
|
+
ttlSec: options?.ttlSec ?? 3600,
|
|
60
|
+
includeJti: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const defaults = options?.defaults ?? {};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
async sign(claims: Record<string, unknown>): Promise<string> {
|
|
67
|
+
return jwt.sign({ ...defaults, ...claims });
|
|
68
|
+
},
|
|
69
|
+
verify: (token: string) => jwt.verify(token),
|
|
70
|
+
decode: (token: string) => jwt.decode(token),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── createTestRoles ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export interface TestRoles {
|
|
77
|
+
/** No token — represents an unauthenticated request. */
|
|
78
|
+
anon: null;
|
|
79
|
+
/** JWT for a regular authenticated user. */
|
|
80
|
+
authenticated: string;
|
|
81
|
+
/** JWT for an admin/super user. */
|
|
82
|
+
admin: string;
|
|
83
|
+
/** Sign a custom token with arbitrary claims. */
|
|
84
|
+
sign(claims: Record<string, unknown>): Promise<string>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create pre-signed tokens for common test roles.
|
|
89
|
+
*
|
|
90
|
+
* Usage:
|
|
91
|
+
* const roles = await createTestRoles(process.env.JWT_SECRET!, {
|
|
92
|
+
* authenticated: { user_id: 42, user_state: 'active' },
|
|
93
|
+
* admin: { user_id: 1, user_state: 'super', role: 'admin' },
|
|
94
|
+
* });
|
|
95
|
+
*
|
|
96
|
+
* // Unauthenticated request
|
|
97
|
+
* await fetch('/api/contents', { headers: {} });
|
|
98
|
+
*
|
|
99
|
+
* // Authenticated request
|
|
100
|
+
* await fetch('/api/rpc/get_user', {
|
|
101
|
+
* headers: { Authorization: `Bearer ${roles.authenticated}` },
|
|
102
|
+
* });
|
|
103
|
+
*
|
|
104
|
+
* // Admin request
|
|
105
|
+
* await fetch('/api/rpc/admin_action', {
|
|
106
|
+
* headers: { Authorization: `Bearer ${roles.admin}` },
|
|
107
|
+
* });
|
|
108
|
+
*/
|
|
109
|
+
export async function createTestRoles(
|
|
110
|
+
secret: string,
|
|
111
|
+
claims: {
|
|
112
|
+
authenticated: Record<string, unknown>;
|
|
113
|
+
admin: Record<string, unknown>;
|
|
114
|
+
},
|
|
115
|
+
): Promise<TestRoles> {
|
|
116
|
+
const auth = createTestAuth(secret);
|
|
117
|
+
const [authenticated, admin] = await Promise.all([
|
|
118
|
+
auth.sign({ role: 'authenticated', ...claims.authenticated }),
|
|
119
|
+
auth.sign({ role: 'admin', ...claims.admin }),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
anon: null,
|
|
124
|
+
authenticated,
|
|
125
|
+
admin,
|
|
126
|
+
sign: (c) => auth.sign(c),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── seedTestUser ────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export interface SeedTestUserOptions {
|
|
133
|
+
/** Email for the test user. */
|
|
134
|
+
email: string;
|
|
135
|
+
/** Plaintext password (stored as bcrypt hash). */
|
|
136
|
+
password: string;
|
|
137
|
+
/** Users table name. Default: 'users'. */
|
|
138
|
+
table?: string;
|
|
139
|
+
/** Email column name. Default: 'email'. */
|
|
140
|
+
emailColumn?: string;
|
|
141
|
+
/** Password column name. Default: 'encrypted_password'. */
|
|
142
|
+
passwordColumn?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Called after the user is created (first run only).
|
|
145
|
+
* Use for app-specific setup: set name, activate, create system lists, etc.
|
|
146
|
+
*/
|
|
147
|
+
afterCreate?: (sql: any, userId: number) => Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface TestUser {
|
|
151
|
+
/** Database ID of the test user. */
|
|
152
|
+
id: number;
|
|
153
|
+
/** Email address. */
|
|
154
|
+
email: string;
|
|
155
|
+
/** Plaintext password (for tests that call auth.sign_in directly). */
|
|
156
|
+
password: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ensure a test user exists with known credentials.
|
|
161
|
+
*
|
|
162
|
+
* Idempotent — creates the user on first run, returns the existing one on
|
|
163
|
+
* subsequent runs. Password is always reset to ensure it matches.
|
|
164
|
+
*
|
|
165
|
+
* Requires a postgres.js sql client (not Drizzle).
|
|
166
|
+
*
|
|
167
|
+
* Usage:
|
|
168
|
+
* const testUser = await seedTestUser(sql, {
|
|
169
|
+
* email: process.env.TEST_USER_EMAIL!,
|
|
170
|
+
* password: process.env.TEST_USER_PASSWORD!,
|
|
171
|
+
* afterCreate: async (sql, userId) => {
|
|
172
|
+
* // App-specific setup: set name, activate, etc.
|
|
173
|
+
* await sql`UPDATE users SET first_name = 'E2E', last_name = 'Test', state = 'active' WHERE id = ${userId}`;
|
|
174
|
+
* },
|
|
175
|
+
* });
|
|
176
|
+
*
|
|
177
|
+
* For deployed stages, store credentials in SST secrets:
|
|
178
|
+
* sst secret set TEST_USER_EMAIL "e2e-test@app.com" --stage dev
|
|
179
|
+
* sst secret set TEST_USER_PASSWORD "test-password" --stage dev
|
|
180
|
+
*/
|
|
181
|
+
export async function seedTestUser(
|
|
182
|
+
sql: any,
|
|
183
|
+
options: SeedTestUserOptions,
|
|
184
|
+
): Promise<TestUser> {
|
|
185
|
+
const {
|
|
186
|
+
email,
|
|
187
|
+
password,
|
|
188
|
+
table = 'users',
|
|
189
|
+
emailColumn = 'email',
|
|
190
|
+
passwordColumn = 'encrypted_password',
|
|
191
|
+
afterCreate,
|
|
192
|
+
} = options;
|
|
193
|
+
|
|
194
|
+
if (!email) throw new Error('seedTestUser: email is required');
|
|
195
|
+
if (!password) throw new Error('seedTestUser: password is required');
|
|
196
|
+
assertIdentifier(table, 'table');
|
|
197
|
+
assertIdentifier(emailColumn, 'emailColumn');
|
|
198
|
+
assertIdentifier(passwordColumn, 'passwordColumn');
|
|
199
|
+
|
|
200
|
+
const hash = await hashPassword(password);
|
|
201
|
+
|
|
202
|
+
// Check if user exists
|
|
203
|
+
const existing = await sql.unsafe(
|
|
204
|
+
`SELECT id::int FROM ${table} WHERE ${emailColumn} = $1 LIMIT 1`,
|
|
205
|
+
[email],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (existing.length > 0) {
|
|
209
|
+
// Ensure password matches (may have been rotated)
|
|
210
|
+
await sql.unsafe(
|
|
211
|
+
`UPDATE ${table} SET ${passwordColumn} = $1 WHERE id = $2`,
|
|
212
|
+
[hash, existing[0].id],
|
|
213
|
+
);
|
|
214
|
+
return { id: existing[0].id, email, password };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create the user with minimal columns — apps can customize via afterCreate
|
|
218
|
+
const created = await sql.unsafe(
|
|
219
|
+
`INSERT INTO ${table} (${emailColumn}, ${passwordColumn}, created_at, updated_at)
|
|
220
|
+
VALUES ($1, $2, NOW(), NOW())
|
|
221
|
+
RETURNING id::int`,
|
|
222
|
+
[email, hash],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const userId = created[0].id;
|
|
226
|
+
|
|
227
|
+
if (afterCreate) {
|
|
228
|
+
await afterCreate(sql, userId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { id: userId, email, password };
|
|
232
|
+
}
|