astro-sessionkit 0.1.15 → 0.1.16
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/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +27 -4
- package/dist/core/config.js.map +1 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/guardMiddleware.d.ts.map +1 -1
- package/dist/core/guardMiddleware.js +3 -2
- package/dist/core/guardMiddleware.js.map +1 -1
- package/dist/core/logger.js +6 -15
- package/dist/core/logger.js.map +1 -1
- package/dist/core/matcher.js.map +1 -1
- package/dist/core/sessionMiddleware.d.ts.map +1 -1
- package/dist/core/sessionMiddleware.js +4 -4
- package/dist/core/sessionMiddleware.js.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/validation.js.map +1 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.js.map +1 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/readme.md +40 -1
- package/src/core/config.ts +153 -0
- package/src/core/context.ts +30 -0
- package/src/core/guardMiddleware.ts +131 -0
- package/src/core/logger.ts +31 -0
- package/src/core/matcher.ts +61 -0
- package/src/core/sessionMiddleware.ts +65 -0
- package/src/core/types.ts +147 -0
- package/src/core/validation.ts +137 -0
- package/src/guard.ts +7 -0
- package/src/index.ts +78 -0
- package/src/integration.ts +58 -0
- package/src/middleware.ts +5 -0
- package/src/server.ts +245 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Route Guard Middleware - Enforces protection rules
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type {APIContext, MiddlewareHandler} from "astro";
|
|
6
|
+
import { getContextStore } from "./context";
|
|
7
|
+
import { getConfig } from "./config";
|
|
8
|
+
import { matchesPattern } from "./matcher";
|
|
9
|
+
import type { ProtectionRule, Session } from "./types";
|
|
10
|
+
import { isValidSessionStructure } from "./validation";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if session satisfies a protection rule
|
|
14
|
+
*/
|
|
15
|
+
async function checkRule(rule: ProtectionRule, session: Session | null): Promise<boolean> {
|
|
16
|
+
const { access } = getConfig();
|
|
17
|
+
|
|
18
|
+
// Custom check overrides everything
|
|
19
|
+
if (access.check) {
|
|
20
|
+
try {
|
|
21
|
+
return await access.check(rule, session);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
24
|
+
console.error('[SessionKit] Error in custom access check hook:', error);
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Custom allow function
|
|
31
|
+
if ("allow" in rule) {
|
|
32
|
+
try {
|
|
33
|
+
return await rule.allow(session);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
36
|
+
console.error('[SessionKit] Error in custom rule allow function:', error);
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Must be authenticated and have a valid session structure for all other checks
|
|
43
|
+
if (!session || !isValidSessionStructure(session)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Single role check
|
|
48
|
+
if ("role" in rule) {
|
|
49
|
+
const userRole = access.getRole(session);
|
|
50
|
+
return userRole === rule.role;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Multiple roles check (user must have ONE of these)
|
|
54
|
+
if ("roles" in rule) {
|
|
55
|
+
const userRole = access.getRole(session);
|
|
56
|
+
return userRole !== null && rule.roles.includes(userRole);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Single permission check
|
|
60
|
+
if ("permission" in rule) {
|
|
61
|
+
const userPermissions = access.getPermissions(session);
|
|
62
|
+
return userPermissions.includes(rule.permission);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Multiple permissions check (user must have ALL of these)
|
|
66
|
+
if ("permissions" in rule) {
|
|
67
|
+
const userPermissions = access.getPermissions(session);
|
|
68
|
+
return rule.permissions.every((p) => userPermissions.includes(p));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// No specific rule matched - allow by default
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create route guard middleware
|
|
77
|
+
*/
|
|
78
|
+
export function createGuardMiddleware(): MiddlewareHandler {
|
|
79
|
+
return async (context : APIContext, next) => {
|
|
80
|
+
const { protect, loginPath, globalProtect, exclude } = getConfig();
|
|
81
|
+
|
|
82
|
+
// No rules configured and no global protect - skip
|
|
83
|
+
if (protect.length === 0 && !globalProtect) {
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let pathname: string;
|
|
88
|
+
try {
|
|
89
|
+
pathname = new URL(context.request.url).pathname;
|
|
90
|
+
} catch {
|
|
91
|
+
// Fallback if URL is invalid (unlikely in Astro)
|
|
92
|
+
pathname = "/";
|
|
93
|
+
}
|
|
94
|
+
const sessionContext = getContextStore();
|
|
95
|
+
const session = sessionContext?.session ?? null;
|
|
96
|
+
|
|
97
|
+
// Find matching rule
|
|
98
|
+
const rule = protect.find((r) => matchesPattern(r.pattern, pathname));
|
|
99
|
+
|
|
100
|
+
// No matching rule - check global protection
|
|
101
|
+
if (!rule) {
|
|
102
|
+
if (globalProtect) {
|
|
103
|
+
// Skip if path is in exclude list
|
|
104
|
+
if (exclude.some((pattern) => matchesPattern(pattern, pathname))) {
|
|
105
|
+
return next();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Skip if it's the login page itself (to avoid redirect loops)
|
|
109
|
+
if (pathname === loginPath) {
|
|
110
|
+
return next();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Require valid session
|
|
114
|
+
if (!session || !isValidSessionStructure(session)) {
|
|
115
|
+
return context.redirect(loginPath);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return next();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check if access is allowed
|
|
122
|
+
const allowed = await checkRule(rule, session);
|
|
123
|
+
|
|
124
|
+
if (!allowed) {
|
|
125
|
+
const redirectTo = rule.redirectTo ?? loginPath;
|
|
126
|
+
return context.redirect(redirectTo);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log message if debug mode is enabled
|
|
5
|
+
*/
|
|
6
|
+
export function debug(message: string, ...args: any[]): void {
|
|
7
|
+
const { debug } = getConfig();
|
|
8
|
+
if (debug) {
|
|
9
|
+
console.debug(`[SessionKit] ${message}`, ...args);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Log error message. Always logs unless in production, but can be forced via debug flag.
|
|
15
|
+
*/
|
|
16
|
+
export function error(message: string, ...args: any[]): void {
|
|
17
|
+
const { debug } = getConfig();
|
|
18
|
+
if (debug || process.env.NODE_ENV !== 'production') {
|
|
19
|
+
console.error(`[SessionKit] ${message}`, ...args);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Log warning message. Always logs unless in production, but can be forced via debug flag.
|
|
25
|
+
*/
|
|
26
|
+
export function warn(message: string, ...args: any[]): void {
|
|
27
|
+
const { debug } = getConfig();
|
|
28
|
+
if (debug || process.env.NODE_ENV !== 'production') {
|
|
29
|
+
console.warn(`[SessionKit] ${message}`, ...args);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Route Pattern Matching
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
function escapeRegex(str: string): string {
|
|
6
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function globToRegex(pattern: string): RegExp {
|
|
10
|
+
let regex = "";
|
|
11
|
+
let i = 0;
|
|
12
|
+
|
|
13
|
+
while (i < pattern.length) {
|
|
14
|
+
const char = pattern[i];
|
|
15
|
+
const next = pattern[i + 1];
|
|
16
|
+
|
|
17
|
+
// Handle **
|
|
18
|
+
if (char === "*" && next === "*") {
|
|
19
|
+
const isAtEnd = i + 2 === pattern.length;
|
|
20
|
+
const prevIsSlash = i > 0 && pattern[i - 1] === "/";
|
|
21
|
+
|
|
22
|
+
if (prevIsSlash) {
|
|
23
|
+
// Handle "/**"
|
|
24
|
+
if (isAtEnd) {
|
|
25
|
+
// "/**" at end matches everything from that point
|
|
26
|
+
if (regex.endsWith("/")) regex = regex.slice(0, -1);
|
|
27
|
+
regex += "(?:/.*)?";
|
|
28
|
+
} else if (pattern[i + 2] === "/") {
|
|
29
|
+
// "/**/" matches zero or more segments
|
|
30
|
+
if (regex.endsWith("/")) regex = regex.slice(0, -1);
|
|
31
|
+
regex += "(?:/.*)?";
|
|
32
|
+
i += 1; // skip one extra for the trailing slash
|
|
33
|
+
} else {
|
|
34
|
+
regex += ".*";
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
regex += ".*";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle *
|
|
45
|
+
if (char === "*") {
|
|
46
|
+
// one or more segments (to maintain backward compatibility with previous tests)
|
|
47
|
+
regex += "[^/]+(?:/[^/]+)*";
|
|
48
|
+
i += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
regex += escapeRegex(char as string);
|
|
53
|
+
i += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new RegExp(`^${regex}$`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function matchesPattern(pattern: string, path: string): boolean {
|
|
60
|
+
return globToRegex(pattern).test(path);
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Session Middleware - Loads session into AsyncLocalStorage
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type {MiddlewareHandler} from "astro";
|
|
6
|
+
import {runWithContext as defaultRunWithContext} from "./context";
|
|
7
|
+
import {isValidSessionStructure} from "./validation";
|
|
8
|
+
import type {Session} from "./types";
|
|
9
|
+
import {getConfig} from "./config";
|
|
10
|
+
import {warn} from "./logger";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Session key used to store session in context.session
|
|
14
|
+
*/
|
|
15
|
+
const SESSION_KEY = "__session__";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Main session middleware
|
|
19
|
+
*
|
|
20
|
+
* Reads session from context.session.get('__session__') and makes it available
|
|
21
|
+
* throughout the request via AsyncLocalStorage
|
|
22
|
+
*/
|
|
23
|
+
export const sessionMiddleware: MiddlewareHandler = async (context, next) => {
|
|
24
|
+
// Get session from context.session store
|
|
25
|
+
const rawSession = context.session?.get<Session>(SESSION_KEY) ?? null;
|
|
26
|
+
|
|
27
|
+
// Validate session structure if present
|
|
28
|
+
let session: Session | null = null;
|
|
29
|
+
|
|
30
|
+
if (rawSession) {
|
|
31
|
+
if (isValidSessionStructure(rawSession)) {
|
|
32
|
+
session = rawSession;
|
|
33
|
+
} else {
|
|
34
|
+
// Invalid session structure - log warning and treat as unauthenticated
|
|
35
|
+
warn(
|
|
36
|
+
'Invalid session structure detected. Session will be ignored. ' +
|
|
37
|
+
'Ensure context.session.set("__session__", ...) has the correct structure. ' +
|
|
38
|
+
'Received: ' + JSON.stringify(rawSession)
|
|
39
|
+
);
|
|
40
|
+
session = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Run the rest of the request chain with session context
|
|
45
|
+
const config = getConfig();
|
|
46
|
+
|
|
47
|
+
// If getContextStore is provided, but runWithContext is NOT,
|
|
48
|
+
// we assume the user is managing the context at a superior level
|
|
49
|
+
// and we should NOT wrap the call in our default runner.
|
|
50
|
+
if (config.getContextStore && !config.runWithContext) {
|
|
51
|
+
// Initialize context store if setter is provided
|
|
52
|
+
const store = config.getContextStore();
|
|
53
|
+
if (store) {
|
|
54
|
+
store.session = session;
|
|
55
|
+
} else if (config.setContextStore) {
|
|
56
|
+
config.setContextStore({session});
|
|
57
|
+
} else if (process.env.NODE_ENV !== 'production') {
|
|
58
|
+
console.error('[SessionKit] getContextStore returned undefined, cannot set session');
|
|
59
|
+
}
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const runner = config.runWithContext ?? defaultRunWithContext;
|
|
64
|
+
return runner({session}, () => next());
|
|
65
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Core Session Types
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The session object stored in context.locals.session
|
|
7
|
+
* This is what your Astro app provides - we just read it.
|
|
8
|
+
*/
|
|
9
|
+
export interface Session {
|
|
10
|
+
/** Unique user identifier */
|
|
11
|
+
userId: string;
|
|
12
|
+
|
|
13
|
+
/** User's email (optional) */
|
|
14
|
+
email?: string;
|
|
15
|
+
|
|
16
|
+
/** Primary role */
|
|
17
|
+
role?: string;
|
|
18
|
+
|
|
19
|
+
/** Additional roles for multi-role scenarios */
|
|
20
|
+
roles?: string[];
|
|
21
|
+
|
|
22
|
+
/** Fine-grained permissions */
|
|
23
|
+
permissions?: string[];
|
|
24
|
+
|
|
25
|
+
/** Any additional custom data */
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What we store in AsyncLocalStorage
|
|
31
|
+
*/
|
|
32
|
+
export interface SessionContext {
|
|
33
|
+
session: Session | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Route Protection Rules
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
interface BaseProtectionRule {
|
|
41
|
+
/** Glob pattern for route matching: "/admin/**", "/dashboard/*", "/settings" */
|
|
42
|
+
pattern: string;
|
|
43
|
+
|
|
44
|
+
/** Where to redirect if access denied (defaults to global loginPath) */
|
|
45
|
+
redirectTo?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Protect by single role */
|
|
49
|
+
export interface RoleProtectionRule extends BaseProtectionRule {
|
|
50
|
+
role: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Protect by multiple roles (user must have ONE of these) */
|
|
54
|
+
export interface RolesProtectionRule extends BaseProtectionRule {
|
|
55
|
+
roles: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Protect by single permission */
|
|
59
|
+
export interface PermissionProtectionRule extends BaseProtectionRule {
|
|
60
|
+
permission: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Protect by multiple permissions (user must have ALL of these) */
|
|
64
|
+
export interface PermissionsProtectionRule extends BaseProtectionRule {
|
|
65
|
+
permissions: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Protect with custom function */
|
|
69
|
+
export interface CustomProtectionRule extends BaseProtectionRule {
|
|
70
|
+
allow: (session: Session | null) => boolean | Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Union of all protection rule types */
|
|
74
|
+
export type ProtectionRule =
|
|
75
|
+
| RoleProtectionRule
|
|
76
|
+
| RolesProtectionRule
|
|
77
|
+
| PermissionProtectionRule
|
|
78
|
+
| PermissionsProtectionRule
|
|
79
|
+
| CustomProtectionRule;
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Configuration
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Optional hooks for custom role/permission extraction
|
|
87
|
+
*/
|
|
88
|
+
export interface AccessHooks {
|
|
89
|
+
/** Extract role from session (default: session.role) */
|
|
90
|
+
getRole?: (session: Session | null) => string | null;
|
|
91
|
+
|
|
92
|
+
/** Extract permissions from session (default: session.permissions ?? []) */
|
|
93
|
+
getPermissions?: (session: Session | null) => string[];
|
|
94
|
+
|
|
95
|
+
/** Custom access check that overrides all built-in logic */
|
|
96
|
+
check?: (rule: ProtectionRule, session: Session | null) => boolean | Promise<boolean>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* SessionKit configuration
|
|
101
|
+
*/
|
|
102
|
+
export interface SessionKitConfig {
|
|
103
|
+
/** Default redirect path for protected routes (default: "/login") */
|
|
104
|
+
loginPath?: string;
|
|
105
|
+
|
|
106
|
+
/** Route protection rules */
|
|
107
|
+
protect?: ProtectionRule[];
|
|
108
|
+
|
|
109
|
+
/** Custom access hooks */
|
|
110
|
+
access?: AccessHooks;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Custom session context runner (optional)
|
|
114
|
+
* Use this to provide your own AsyncLocalStorage implementation or wrap the request
|
|
115
|
+
*/
|
|
116
|
+
runWithContext?: <T>(context: SessionContext, fn: () => T) => T;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Custom session context getter (optional)
|
|
120
|
+
* Use this to provide your own way to retrieve the session context
|
|
121
|
+
*/
|
|
122
|
+
getContextStore?: () => SessionContext | undefined;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Custom session context setter (optional)
|
|
126
|
+
* Use this to provide your own way to initialize the session context
|
|
127
|
+
*/
|
|
128
|
+
setContextStore?: (context: SessionContext) => void;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Enable global session verification for all routes.
|
|
132
|
+
* If true, any route not explicitly ignored will require an active session.
|
|
133
|
+
* @default false
|
|
134
|
+
*/
|
|
135
|
+
globalProtect?: boolean;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Routes to exclude from global protection (only if globalProtect is true)
|
|
139
|
+
*/
|
|
140
|
+
exclude?: string[];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Enable debug logging
|
|
144
|
+
* @default false
|
|
145
|
+
*/
|
|
146
|
+
debug?: boolean;
|
|
147
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Security Validation Utilities
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type {Session} from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate that session has the expected structure
|
|
9
|
+
* Prevents crashes from malformed data and DoS attacks
|
|
10
|
+
*/
|
|
11
|
+
export function isValidSessionStructure(input: unknown): input is Session {
|
|
12
|
+
// Must be an object
|
|
13
|
+
if (!input || typeof input !== 'object') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const session = input as any;
|
|
18
|
+
|
|
19
|
+
// Required: userId must be a non-empty string
|
|
20
|
+
if (typeof session.userId !== 'string' || !session.userId.trim()) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// DoS protection: Limit userId length
|
|
25
|
+
if (session.userId.length > 255) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Optional fields validation (if present)
|
|
30
|
+
if (session.email !== undefined) {
|
|
31
|
+
if (typeof session.email !== 'string') {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
// Reasonable email length limit
|
|
35
|
+
if (session.email.length > 320) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (session.role !== undefined && session.role !== null) {
|
|
41
|
+
if (typeof session.role !== 'string') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
// Reasonable role length limit
|
|
45
|
+
if (session.role.length > 100) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (session.roles !== undefined && session.roles !== null) {
|
|
51
|
+
if (!Array.isArray(session.roles)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
// DoS protection: Limit array size
|
|
55
|
+
if (session.roles.length > 100) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// All items must be strings with reasonable length
|
|
59
|
+
if (!session.roles.every((r: any) => typeof r === 'string' && r.length <= 100)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (session.permissions !== undefined && session.permissions !== null) {
|
|
65
|
+
if (!Array.isArray(session.permissions)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// DoS protection: Limit array size
|
|
69
|
+
if (session.permissions.length > 500) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// All items must be strings with reasonable length
|
|
73
|
+
if (!session.permissions.every((p: any) => typeof p === 'string' && p.length <= 200)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate route patterns to prevent ReDoS attacks
|
|
83
|
+
*/
|
|
84
|
+
export function isValidPattern(pattern: string): boolean {
|
|
85
|
+
|
|
86
|
+
if (typeof pattern !== 'string' || pattern.length === 0) return false;
|
|
87
|
+
|
|
88
|
+
// Length limit
|
|
89
|
+
if (pattern.length > 1000) return false;
|
|
90
|
+
|
|
91
|
+
// Must start with /
|
|
92
|
+
if (!pattern.startsWith("/")) return false;
|
|
93
|
+
|
|
94
|
+
// ReDoS protection: reject 4+ consecutive asterisks anywhere
|
|
95
|
+
if (/\*{4,}/.test(pattern)) return false;
|
|
96
|
+
|
|
97
|
+
// Additional sanity: ensure any '*' run is 1..3
|
|
98
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
99
|
+
if (pattern[i] !== "*") continue;
|
|
100
|
+
|
|
101
|
+
let j = i;
|
|
102
|
+
while (j < pattern.length && pattern[j] === "*") j++;
|
|
103
|
+
|
|
104
|
+
const run = j - i; // consecutive '*'
|
|
105
|
+
if (run < 1 || run > 3) return false; // (4+ already blocked above)
|
|
106
|
+
|
|
107
|
+
// Optional: keep your patterns segment-oriented.
|
|
108
|
+
// Wildcards should not be glued to letters (e.g., "/**abc").
|
|
109
|
+
const next = pattern[j];
|
|
110
|
+
if (next !== undefined && next !== "/") return false;
|
|
111
|
+
|
|
112
|
+
i = j - 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate redirect path (open redirect protection)
|
|
120
|
+
*/
|
|
121
|
+
export function isValidRedirectPath(path: string): boolean {
|
|
122
|
+
|
|
123
|
+
if (typeof path !== 'string') return false;
|
|
124
|
+
|
|
125
|
+
// Reasonable length limit
|
|
126
|
+
if (path.length === 0 || path.length > 500) return false;
|
|
127
|
+
|
|
128
|
+
// Must be a site-relative path (exactly one leading slash)
|
|
129
|
+
// Reject protocol-relative URLs like "//example.com"
|
|
130
|
+
if (!path.startsWith("/") || path.startsWith("//")) return false;
|
|
131
|
+
|
|
132
|
+
// Extra hardening: reject anything that looks like a URL scheme
|
|
133
|
+
// (e.g. "http://", "https://", "javascript:", "data:", etc.)
|
|
134
|
+
return !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path);
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Guard Middleware Entry Point
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { createGuardMiddleware } from "./core/guardMiddleware";
|
|
6
|
+
|
|
7
|
+
export const onRequest = createGuardMiddleware();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Astro SessionKit - Main Integration Entry Point
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type {AstroIntegration } from "astro";
|
|
6
|
+
import { setConfig } from "./core/config";
|
|
7
|
+
import type { SessionKitConfig } from "./core/types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SessionKit - Simple session access and route protection for Astro
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* // astro.config.mjs
|
|
15
|
+
* import sessionkit from 'astro-sessionkit';
|
|
16
|
+
*
|
|
17
|
+
* export default defineConfig({
|
|
18
|
+
* integrations: [
|
|
19
|
+
* sessionkit({
|
|
20
|
+
* loginPath: '/login',
|
|
21
|
+
* protect: [
|
|
22
|
+
* { pattern: '/admin/**', role: 'admin' },
|
|
23
|
+
* { pattern: '/dashboard', roles: ['user', 'admin'] },
|
|
24
|
+
* { pattern: '/settings', permissions: ['settings:write'] }
|
|
25
|
+
* ]
|
|
26
|
+
* })
|
|
27
|
+
* ]
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export default function sessionkit(config: SessionKitConfig = {}): AstroIntegration {
|
|
32
|
+
// Store configuration
|
|
33
|
+
setConfig(config);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: "astro-sessionkit",
|
|
37
|
+
hooks: {
|
|
38
|
+
"astro:config:setup": ({ addMiddleware }) => {
|
|
39
|
+
// 1. Always add session context middleware first
|
|
40
|
+
addMiddleware({
|
|
41
|
+
entrypoint: "astro-sessionkit/middleware",
|
|
42
|
+
order: "pre",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 2. Add route guard if there are protection rules or global protection is enabled
|
|
46
|
+
if ((config.protect && config.protect.length > 0) || config.globalProtect) {
|
|
47
|
+
addMiddleware({
|
|
48
|
+
entrypoint: "astro-sessionkit/guard",
|
|
49
|
+
order: "pre",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Re-export types for convenience
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
export type {
|
|
62
|
+
Session,
|
|
63
|
+
ProtectionRule,
|
|
64
|
+
RoleProtectionRule,
|
|
65
|
+
RolesProtectionRule,
|
|
66
|
+
PermissionProtectionRule,
|
|
67
|
+
PermissionsProtectionRule,
|
|
68
|
+
CustomProtectionRule,
|
|
69
|
+
SessionKitConfig,
|
|
70
|
+
AccessHooks,
|
|
71
|
+
SessionContext
|
|
72
|
+
} from "./core/types";
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Version export
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
export const version = "0.1.0";
|