@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/plugin.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/auth/plugin — Auth routes + layered security as a composable plugin.
|
|
3
|
+
*
|
|
4
|
+
* Registers signup, signin, refresh, and signout routes.
|
|
5
|
+
* Optionally decorates verifyToken with audience, client, device, and custom checks.
|
|
6
|
+
* Optionally contributes client management CLI actions.
|
|
7
|
+
*
|
|
8
|
+
* Note: Types are inlined to avoid circular dependency with @everystack/server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Inlined Server Types (avoid circular dep) ──────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Minimal plugin context (compatible with @everystack/server/plugin PluginContext) */
|
|
14
|
+
interface PluginContext {
|
|
15
|
+
db: any;
|
|
16
|
+
schema: Record<string, any>;
|
|
17
|
+
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
18
|
+
environment: string;
|
|
19
|
+
publishJob: (type: string, payload: unknown) => Promise<string>;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal route type (compatible with @everystack/server Route) */
|
|
24
|
+
interface Route {
|
|
25
|
+
path: string;
|
|
26
|
+
method?: string;
|
|
27
|
+
exact?: boolean;
|
|
28
|
+
handler: (req: Request) => Promise<Response>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ActionHandler = (payload: unknown, ctx: PluginContext) => Promise<unknown>;
|
|
32
|
+
|
|
33
|
+
interface PluginContribution {
|
|
34
|
+
routes?: Route[];
|
|
35
|
+
actions?: Record<string, ActionHandler>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Plugin = (ctx: PluginContext) => Promise<PluginContribution>;
|
|
39
|
+
|
|
40
|
+
// ─── Auth Plugin Types ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export type EnrichClaimsFn = (
|
|
43
|
+
baseClaims: Record<string, unknown>,
|
|
44
|
+
request: Request,
|
|
45
|
+
) => Promise<Record<string, unknown>>;
|
|
46
|
+
|
|
47
|
+
export interface ClientCallbacks {
|
|
48
|
+
resolve: (clientId: string) => Promise<{
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
active: boolean;
|
|
52
|
+
audience?: string;
|
|
53
|
+
[k: string]: unknown;
|
|
54
|
+
} | null>;
|
|
55
|
+
register?: (name: string) => Promise<{ clientId: string; secret: string }>;
|
|
56
|
+
rotate?: (clientId: string) => Promise<{ secret: string }>;
|
|
57
|
+
revoke?: (clientId: string) => Promise<void>;
|
|
58
|
+
list?: () => Promise<Array<{
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
clientId: string;
|
|
62
|
+
active: boolean;
|
|
63
|
+
createdAt: Date;
|
|
64
|
+
}>>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DeviceCallbacks {
|
|
68
|
+
verify: (attestation: unknown, claims: Record<string, unknown>) => Promise<boolean>;
|
|
69
|
+
require?: 'always' | 'write' | 'never';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AuthPluginOptions {
|
|
73
|
+
/** Base path for auth routes (default: '/api/auth') */
|
|
74
|
+
basePath?: string;
|
|
75
|
+
/** Expected audience claim — rejects tokens with wrong/missing aud */
|
|
76
|
+
audience?: string;
|
|
77
|
+
/** Client resolution and management callbacks */
|
|
78
|
+
clients?: (ctx: PluginContext) => ClientCallbacks;
|
|
79
|
+
/** Device attestation verification */
|
|
80
|
+
device?: DeviceCallbacks;
|
|
81
|
+
/** Custom claims validation — return false to reject */
|
|
82
|
+
verifyClaims?: (claims: Record<string, unknown>) => Promise<boolean>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── enrichClaims Forward Reference ──────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates a mutable ref + stable function for enrichClaims.
|
|
89
|
+
*
|
|
90
|
+
* The context factory creates auth handlers before plugins init. The plugin
|
|
91
|
+
* needs to inject enrichClaims behavior after init. This forward reference
|
|
92
|
+
* pattern bridges that gap.
|
|
93
|
+
*
|
|
94
|
+
* Usage in app context factory:
|
|
95
|
+
* const enrichClaims = createEnrichRef();
|
|
96
|
+
* const auth = createAuthHandlers(db, schema, jwtSecret, {
|
|
97
|
+
* enrichClaims: enrichClaims.fn,
|
|
98
|
+
* });
|
|
99
|
+
* return { ..., enrichClaimsRef: enrichClaims.ref };
|
|
100
|
+
*/
|
|
101
|
+
export function createEnrichRef(): {
|
|
102
|
+
ref: { current: EnrichClaimsFn | null };
|
|
103
|
+
fn: EnrichClaimsFn;
|
|
104
|
+
} {
|
|
105
|
+
const ref: { current: EnrichClaimsFn | null } = { current: null };
|
|
106
|
+
const fn: EnrichClaimsFn = async (claims, request) => {
|
|
107
|
+
return ref.current ? ref.current(claims, request) : {};
|
|
108
|
+
};
|
|
109
|
+
return { ref, fn };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Auth Context Extension ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
interface AuthContext extends PluginContext {
|
|
115
|
+
auth: {
|
|
116
|
+
signup: (req: Request) => Promise<Response>;
|
|
117
|
+
signin: (req: Request) => Promise<Response>;
|
|
118
|
+
refresh: (req: Request) => Promise<Response>;
|
|
119
|
+
signout: (req: Request) => Promise<Response>;
|
|
120
|
+
};
|
|
121
|
+
enrichClaimsRef?: { current: EnrichClaimsFn | null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Plugin Implementation ───────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export function authPlugin(options: AuthPluginOptions = {}): Plugin {
|
|
127
|
+
const basePath = options.basePath ?? '/api/auth';
|
|
128
|
+
|
|
129
|
+
return async (ctx) => {
|
|
130
|
+
const { auth } = ctx as AuthContext;
|
|
131
|
+
const clientCallbacks = options.clients?.(ctx);
|
|
132
|
+
|
|
133
|
+
// --- Decorate verifyToken with layered security ---
|
|
134
|
+
if (options.audience || clientCallbacks || options.device || options.verifyClaims) {
|
|
135
|
+
const originalVerifyToken = ctx.verifyToken;
|
|
136
|
+
const deviceRequire = options.device?.require ?? 'never';
|
|
137
|
+
|
|
138
|
+
ctx.verifyToken = async (token: string): Promise<Record<string, unknown> | null> => {
|
|
139
|
+
const claims = await originalVerifyToken(token);
|
|
140
|
+
if (!claims) return null;
|
|
141
|
+
|
|
142
|
+
// Layer 1: Audience check — when configured, all tokens must carry a
|
|
143
|
+
// matching aud claim. Client-authenticated tokens get aud via enrichClaims.
|
|
144
|
+
if (options.audience) {
|
|
145
|
+
if (claims.aud !== options.audience) return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Layer 2: Client resolution
|
|
149
|
+
if (clientCallbacks && claims.client_id) {
|
|
150
|
+
const client = await clientCallbacks.resolve(claims.client_id as string);
|
|
151
|
+
if (!client || !client.active) return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Layer 3: Device attestation
|
|
155
|
+
if (options.device && deviceRequire === 'always') {
|
|
156
|
+
const passed = await options.device.verify(
|
|
157
|
+
claims.device_attestation ?? null,
|
|
158
|
+
claims,
|
|
159
|
+
);
|
|
160
|
+
if (!passed) return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Layer 4: Custom claims validation
|
|
164
|
+
if (options.verifyClaims) {
|
|
165
|
+
const valid = await options.verifyClaims(claims);
|
|
166
|
+
if (!valid) return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return claims;
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Wire enrichClaims ref ---
|
|
174
|
+
const authCtx = ctx as AuthContext;
|
|
175
|
+
if (authCtx.enrichClaimsRef && clientCallbacks) {
|
|
176
|
+
authCtx.enrichClaimsRef.current = async (
|
|
177
|
+
baseClaims: Record<string, unknown>,
|
|
178
|
+
request: Request,
|
|
179
|
+
): Promise<Record<string, unknown>> => {
|
|
180
|
+
// Check baseClaims first (refresh path — client_id from session data)
|
|
181
|
+
let clientId = baseClaims.client_id as string | undefined;
|
|
182
|
+
|
|
183
|
+
// Fall back to request body (signin/signup path)
|
|
184
|
+
if (!clientId) {
|
|
185
|
+
try {
|
|
186
|
+
const body = await request.clone().json();
|
|
187
|
+
clientId = body?.client_id;
|
|
188
|
+
} catch {
|
|
189
|
+
// No JSON body or no client_id
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!clientId) return {};
|
|
194
|
+
|
|
195
|
+
const client = await clientCallbacks.resolve(clientId);
|
|
196
|
+
if (!client || !client.active) return {};
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
client_id: clientId,
|
|
200
|
+
aud: client.audience ?? options.audience,
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Build contribution ---
|
|
206
|
+
const contribution: PluginContribution = {
|
|
207
|
+
routes: [
|
|
208
|
+
{ path: `${basePath}/signup`, method: 'POST', exact: true, handler: auth.signup },
|
|
209
|
+
{ path: `${basePath}/signin`, method: 'POST', exact: true, handler: auth.signin },
|
|
210
|
+
{ path: `${basePath}/refresh`, method: 'POST', exact: true, handler: auth.refresh },
|
|
211
|
+
{ path: `${basePath}/signout`, method: 'POST', exact: true, handler: auth.signout },
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// --- Client management actions ---
|
|
216
|
+
if (clientCallbacks) {
|
|
217
|
+
const hasManagement = clientCallbacks.register ||
|
|
218
|
+
clientCallbacks.rotate ||
|
|
219
|
+
clientCallbacks.revoke ||
|
|
220
|
+
clientCallbacks.list;
|
|
221
|
+
|
|
222
|
+
if (hasManagement) {
|
|
223
|
+
const actions: Record<string, ActionHandler> = {};
|
|
224
|
+
|
|
225
|
+
if (clientCallbacks.register) {
|
|
226
|
+
const registerFn = clientCallbacks.register;
|
|
227
|
+
actions['clients:register'] = async (payload) => {
|
|
228
|
+
const { name } = payload as { name: string };
|
|
229
|
+
return await registerFn(name);
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (clientCallbacks.rotate) {
|
|
234
|
+
const rotateFn = clientCallbacks.rotate;
|
|
235
|
+
actions['clients:rotate'] = async (payload) => {
|
|
236
|
+
const { clientId } = payload as { clientId: string };
|
|
237
|
+
return await rotateFn(clientId);
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (clientCallbacks.revoke) {
|
|
242
|
+
const revokeFn = clientCallbacks.revoke;
|
|
243
|
+
actions['clients:revoke'] = async (payload) => {
|
|
244
|
+
const { clientId } = payload as { clientId: string };
|
|
245
|
+
await revokeFn(clientId);
|
|
246
|
+
return { revoked: true };
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (clientCallbacks.list) {
|
|
251
|
+
const listFn = clientCallbacks.list;
|
|
252
|
+
actions['clients:list'] = async () => {
|
|
253
|
+
const clients = await listFn();
|
|
254
|
+
return { clients };
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
contribution.actions = actions;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return contribution;
|
|
263
|
+
};
|
|
264
|
+
}
|