@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/README.md
CHANGED
|
@@ -164,13 +164,7 @@ import { createAuthFunction } from '@everystack/server/cdn';
|
|
|
164
164
|
|
|
165
165
|
const auth = createAuthFunction({
|
|
166
166
|
secret: $output(secret.value),
|
|
167
|
-
|
|
168
|
-
roles: {
|
|
169
|
-
posts: { read: 'public', write: 'authenticated' },
|
|
170
|
-
comments: { read: 'authenticated', write: 'authenticated' },
|
|
171
|
-
'*': { read: 'authenticated', write: 'admin' },
|
|
172
|
-
},
|
|
173
|
-
roleHierarchy: ['public', 'authenticated', 'admin'],
|
|
167
|
+
requireAuth: ['/api/', '!/api/auth/', '!/api/health', '!/api/updates'],
|
|
174
168
|
});
|
|
175
169
|
|
|
176
170
|
new sst.aws.Router('Router', {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/auth",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "JWT authentication handlers, password hashing, and React auth context",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"src",
|
|
11
11
|
"README.md"
|
|
12
12
|
],
|
|
13
|
-
"type": "module",
|
|
14
13
|
"exports": {
|
|
15
14
|
".": {
|
|
16
15
|
"types": "./src/index.ts",
|
|
@@ -66,15 +65,29 @@
|
|
|
66
65
|
"./cff": {
|
|
67
66
|
"types": "./src/cff/index.ts",
|
|
68
67
|
"default": "./src/cff/index.ts"
|
|
68
|
+
},
|
|
69
|
+
"./plugin": {
|
|
70
|
+
"types": "./src/plugin.ts",
|
|
71
|
+
"default": "./src/plugin.ts"
|
|
72
|
+
},
|
|
73
|
+
"./testing": {
|
|
74
|
+
"types": "./src/testing.ts",
|
|
75
|
+
"default": "./src/testing.ts"
|
|
69
76
|
}
|
|
70
77
|
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"test": "jest",
|
|
80
|
+
"build": "tsc --build",
|
|
81
|
+
"lint": "tsc --noEmit"
|
|
82
|
+
},
|
|
71
83
|
"dependencies": {
|
|
72
|
-
"bcryptjs": "
|
|
84
|
+
"bcryptjs": "3.0.3"
|
|
73
85
|
},
|
|
74
86
|
"peerDependencies": {
|
|
75
87
|
"@everystack/api": ">=0.1.0",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
88
|
+
"@everystack/server": ">=0.1.0",
|
|
89
|
+
"drizzle-orm": "0.41.0",
|
|
90
|
+
"react": "19.2.6"
|
|
78
91
|
},
|
|
79
92
|
"peerDependenciesMeta": {
|
|
80
93
|
"react": {
|
|
@@ -82,23 +95,21 @@
|
|
|
82
95
|
},
|
|
83
96
|
"@everystack/api": {
|
|
84
97
|
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"@everystack/server": {
|
|
100
|
+
"optional": true
|
|
85
101
|
}
|
|
86
102
|
},
|
|
87
103
|
"devDependencies": {
|
|
88
|
-
"@
|
|
89
|
-
"@types/
|
|
90
|
-
"@types/
|
|
91
|
-
"@types/
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
},
|
|
99
|
-
"scripts": {
|
|
100
|
-
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
101
|
-
"build": "tsc --build",
|
|
102
|
-
"lint": "tsc --noEmit"
|
|
104
|
+
"@everystack/api": "workspace:*",
|
|
105
|
+
"@types/bcryptjs": "3.0.0",
|
|
106
|
+
"@types/jest": "29.5.14",
|
|
107
|
+
"@types/node": "22.19.18",
|
|
108
|
+
"@types/react": "19.2.14",
|
|
109
|
+
"drizzle-orm": "0.41.0",
|
|
110
|
+
"jest": "29.7.0",
|
|
111
|
+
"postgres": "3.4.9",
|
|
112
|
+
"ts-jest": "29.4.9",
|
|
113
|
+
"typescript": "5.9.3"
|
|
103
114
|
}
|
|
104
|
-
}
|
|
115
|
+
}
|
package/src/api.ts
CHANGED
|
@@ -13,15 +13,19 @@
|
|
|
13
13
|
|
|
14
14
|
import { createClient } from '@everystack/api/client';
|
|
15
15
|
|
|
16
|
-
const TOKEN_KEY = 'everystack-token';
|
|
17
|
-
const REFRESH_KEY = 'everystack-refresh';
|
|
16
|
+
export const TOKEN_KEY = 'everystack-token';
|
|
17
|
+
export const REFRESH_KEY = 'everystack-refresh';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Deduplicated token refresh — only one refresh request in flight at a time.
|
|
21
|
+
* Shared between the api client (onTokenExpired) and rpc().
|
|
22
|
+
*/
|
|
23
|
+
let activeRefresh: Promise<string | null> | null = null;
|
|
24
|
+
|
|
25
|
+
export async function refreshAuthToken(): Promise<string | null> {
|
|
26
|
+
if (activeRefresh) return activeRefresh;
|
|
27
|
+
|
|
28
|
+
activeRefresh = (async () => {
|
|
25
29
|
try {
|
|
26
30
|
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
|
27
31
|
if (!refreshToken) return null;
|
|
@@ -36,11 +40,22 @@ export const api = createClient({
|
|
|
36
40
|
localStorage.setItem(REFRESH_KEY, body.refreshToken);
|
|
37
41
|
return body.token;
|
|
38
42
|
} catch { return null; }
|
|
43
|
+
})().finally(() => { activeRefresh = null; });
|
|
44
|
+
|
|
45
|
+
return activeRefresh;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const api = createClient({
|
|
49
|
+
baseUrl: '/api',
|
|
50
|
+
getToken: () => {
|
|
51
|
+
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
39
52
|
},
|
|
53
|
+
onTokenExpired: refreshAuthToken,
|
|
40
54
|
});
|
|
41
55
|
|
|
42
56
|
/**
|
|
43
57
|
* Call an RPC function on the handler.
|
|
58
|
+
* Retries once on 401 via shared token refresh.
|
|
44
59
|
*/
|
|
45
60
|
export async function rpc<T = unknown>(name: string, body?: unknown): Promise<T> {
|
|
46
61
|
const token = (() => {
|
|
@@ -56,6 +71,25 @@ export async function rpc<T = unknown>(name: string, body?: unknown): Promise<T>
|
|
|
56
71
|
body: body ? JSON.stringify(body) : '{}',
|
|
57
72
|
});
|
|
58
73
|
|
|
74
|
+
if (res.status === 401) {
|
|
75
|
+
const newToken = await refreshAuthToken();
|
|
76
|
+
if (newToken) {
|
|
77
|
+
const retryRes = await fetch(`/api/rpc/${name}`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
Authorization: `Bearer ${newToken}`,
|
|
82
|
+
},
|
|
83
|
+
body: body ? JSON.stringify(body) : '{}',
|
|
84
|
+
});
|
|
85
|
+
if (!retryRes.ok) {
|
|
86
|
+
const err = await retryRes.json().catch(() => ({}));
|
|
87
|
+
throw new Error(err.message || `RPC ${name} failed: ${retryRes.status}`);
|
|
88
|
+
}
|
|
89
|
+
return retryRes.json();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
59
93
|
if (!res.ok) {
|
|
60
94
|
const err = await res.json().catch(() => ({}));
|
|
61
95
|
throw new Error(err.message || `RPC ${name} failed: ${res.status}`);
|
package/src/client/index.tsx
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
15
|
+
import { refreshAuthToken } from '../api';
|
|
15
16
|
|
|
16
17
|
export interface AuthUser {
|
|
17
18
|
id: string;
|
|
@@ -66,19 +67,50 @@ export const storage = {
|
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Check if a JWT access token is expired by decoding the payload.
|
|
72
|
+
* Lightweight client-side check — no crypto, just base64 decode + exp comparison.
|
|
73
|
+
*/
|
|
74
|
+
export function isTokenExpired(token: string): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const parts = token.split('.');
|
|
77
|
+
if (parts.length !== 3) return true;
|
|
78
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
79
|
+
if (typeof payload.exp !== 'number') return true;
|
|
80
|
+
return payload.exp * 1000 < Date.now();
|
|
81
|
+
} catch { return true; }
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
70
85
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
71
86
|
const [token, setToken] = useState<string | null>(null);
|
|
72
87
|
const [isLoading, setIsLoading] = useState(true);
|
|
73
88
|
|
|
74
89
|
useEffect(() => {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
const init = async () => {
|
|
91
|
+
const savedToken = storage.getToken();
|
|
92
|
+
const savedUser = storage.getUser();
|
|
93
|
+
|
|
94
|
+
if (savedToken && savedUser) {
|
|
95
|
+
if (!isTokenExpired(savedToken)) {
|
|
96
|
+
setToken(savedToken);
|
|
97
|
+
setUser(savedUser);
|
|
98
|
+
} else {
|
|
99
|
+
// Token expired — attempt refresh before mounting authenticated UI
|
|
100
|
+
const newToken = await refreshAuthToken();
|
|
101
|
+
if (newToken) {
|
|
102
|
+
setToken(newToken);
|
|
103
|
+
setUser(savedUser);
|
|
104
|
+
} else {
|
|
105
|
+
storage.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setIsLoading(false);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
init();
|
|
82
114
|
}, []);
|
|
83
115
|
|
|
84
116
|
const signIn = useCallback(async (email: string, password: string) => {
|
package/src/cookies.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface CookieOptions {
|
|
|
22
22
|
tokenMaxAge?: number;
|
|
23
23
|
/** Refresh token max-age in seconds. Default 604800 (7 days). */
|
|
24
24
|
refreshMaxAge?: number;
|
|
25
|
+
/** Origins allowed for CSRF validation on cookie-authenticated POST requests.
|
|
26
|
+
* When set, POST requests with cookies must include a matching Origin header. */
|
|
27
|
+
allowedOrigins?: string[];
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
package/src/csrf.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF Origin validation for cookie-based auth requests.
|
|
3
|
+
*
|
|
4
|
+
* When auth cookies are enabled, mutating requests (POST) must include
|
|
5
|
+
* an Origin or Referer header matching one of the allowed origins.
|
|
6
|
+
* Requests without cookies are not checked (mobile/API clients).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate that the request origin is allowed.
|
|
11
|
+
* Returns null if valid, or a Response with 403 if rejected.
|
|
12
|
+
*
|
|
13
|
+
* Only checks requests that carry cookies (Cookie header present).
|
|
14
|
+
* Non-cookie requests (mobile/API) are always allowed.
|
|
15
|
+
*/
|
|
16
|
+
export function validateCsrfOrigin(
|
|
17
|
+
request: Request,
|
|
18
|
+
allowedOrigins?: string[],
|
|
19
|
+
): Response | null {
|
|
20
|
+
// No origins configured -- skip check
|
|
21
|
+
if (!allowedOrigins || allowedOrigins.length === 0) return null;
|
|
22
|
+
|
|
23
|
+
// No cookie header -- not a cookie-based request (mobile/API)
|
|
24
|
+
if (!request.headers.get('cookie')) return null;
|
|
25
|
+
|
|
26
|
+
// Extract origin: prefer Origin header, fall back to Referer
|
|
27
|
+
let origin = request.headers.get('origin');
|
|
28
|
+
if (!origin) {
|
|
29
|
+
const referer = request.headers.get('referer');
|
|
30
|
+
if (referer) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(referer);
|
|
33
|
+
origin = url.origin;
|
|
34
|
+
} catch {
|
|
35
|
+
// Malformed referer -- reject
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!origin) {
|
|
41
|
+
return Response.json(
|
|
42
|
+
{ message: 'Origin header required for cookie-authenticated requests' },
|
|
43
|
+
{ status: 403 },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Normalize: lowercase, strip trailing slash
|
|
48
|
+
const normalized = origin.toLowerCase().replace(/\/+$/, '');
|
|
49
|
+
const allowed = allowedOrigins.map(o => o.toLowerCase().replace(/\/+$/, ''));
|
|
50
|
+
|
|
51
|
+
if (allowed.includes(normalized)) return null;
|
|
52
|
+
|
|
53
|
+
return Response.json(
|
|
54
|
+
{ message: 'Origin not allowed' },
|
|
55
|
+
{ status: 403 },
|
|
56
|
+
);
|
|
57
|
+
}
|