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.
@@ -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";