astro-sessionkit 0.1.14 → 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 +3 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +40 -0
- 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 +15 -3
- package/dist/core/guardMiddleware.js.map +1 -1
- package/dist/core/logger.d.ts +4 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +11 -0
- package/dist/core/logger.js.map +1 -0
- 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.d.ts +3 -0
- package/dist/core/types.d.ts.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.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.js +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,153 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Configuration Store
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import type {SessionKitConfig, AccessHooks, ProtectionRule, Session, SessionContext} from "./types";
|
|
6
|
+
import {isValidPattern, isValidRedirectPath} from "./validation";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal config with defaults applied
|
|
10
|
+
*/
|
|
11
|
+
export interface ResolvedConfig {
|
|
12
|
+
loginPath: string;
|
|
13
|
+
protect: ProtectionRule[];
|
|
14
|
+
access: Required<Omit<AccessHooks, "check">> & {
|
|
15
|
+
check?: AccessHooks["check"];
|
|
16
|
+
};
|
|
17
|
+
runWithContext?: <T>(context: SessionContext, fn: () => T) => T | Promise<T>;
|
|
18
|
+
getContextStore?: () => SessionContext | undefined;
|
|
19
|
+
setContextStore?: (context: SessionContext) => void;
|
|
20
|
+
globalProtect: boolean;
|
|
21
|
+
exclude: string[];
|
|
22
|
+
debug: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CONFIG: ResolvedConfig = {
|
|
26
|
+
loginPath: "/login",
|
|
27
|
+
protect: [],
|
|
28
|
+
access: {
|
|
29
|
+
getRole: (session: Session | null) => session?.role ?? null,
|
|
30
|
+
getPermissions: (session: Session | null) => session?.permissions ?? [],
|
|
31
|
+
check: undefined,
|
|
32
|
+
},
|
|
33
|
+
globalProtect: false,
|
|
34
|
+
exclude: [],
|
|
35
|
+
debug: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let config: ResolvedConfig = { ...DEFAULT_CONFIG };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set configuration (called by integration)
|
|
42
|
+
*/
|
|
43
|
+
export function setConfig(userConfig: SessionKitConfig): void {
|
|
44
|
+
// Start with default config
|
|
45
|
+
const newConfig: ResolvedConfig = { ...DEFAULT_CONFIG };
|
|
46
|
+
|
|
47
|
+
// Validate and set loginPath
|
|
48
|
+
if (userConfig.loginPath !== undefined) {
|
|
49
|
+
if (!isValidRedirectPath(userConfig.loginPath)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[SessionKit] Invalid loginPath: "${userConfig.loginPath}". Must start with / and be less than 500 characters.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
newConfig.loginPath = userConfig.loginPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Validate protection rules
|
|
58
|
+
if (userConfig.protect) {
|
|
59
|
+
for (const rule of userConfig.protect) {
|
|
60
|
+
// Validate pattern
|
|
61
|
+
if (!isValidPattern(rule.pattern)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[SessionKit] Invalid pattern: "${rule.pattern}". ` +
|
|
64
|
+
`Patterns must start with / and be less than 1000 characters.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate redirectTo if present
|
|
69
|
+
if (rule.redirectTo && !isValidRedirectPath(rule.redirectTo)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[SessionKit] Invalid redirectTo: "${rule.redirectTo}". ` +
|
|
72
|
+
`Must start with / and be less than 500 characters.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
newConfig.protect = [...userConfig.protect];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate context store getter/setter pair
|
|
80
|
+
if ((userConfig.getContextStore && !userConfig.setContextStore) || (!userConfig.getContextStore && userConfig.setContextStore)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'[SessionKit] Both getContextStore and setContextStore must be provided together if using custom context storage.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Set access hooks
|
|
87
|
+
if (userConfig.access) {
|
|
88
|
+
newConfig.access = {
|
|
89
|
+
getRole: userConfig.access.getRole ?? DEFAULT_CONFIG.access.getRole,
|
|
90
|
+
getPermissions: userConfig.access.getPermissions ?? DEFAULT_CONFIG.access.getPermissions,
|
|
91
|
+
check: userConfig.access.check,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Migration/Safety: If user misplaced globalProtect/exclude/debug in access object,
|
|
95
|
+
// we honor them but warn about it.
|
|
96
|
+
const anyAccess = userConfig.access as any;
|
|
97
|
+
if (anyAccess.globalProtect !== undefined && userConfig.globalProtect === undefined) {
|
|
98
|
+
newConfig.globalProtect = anyAccess.globalProtect;
|
|
99
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
100
|
+
console.warn('[SessionKit] Deprecation: globalProtect should be at the top level of configuration, not inside "access".');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (anyAccess.exclude !== undefined && userConfig.exclude === undefined) {
|
|
104
|
+
newConfig.exclude = anyAccess.exclude;
|
|
105
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
106
|
+
console.warn('[SessionKit] Deprecation: exclude should be at the top level of configuration, not inside "access".');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (anyAccess.debug !== undefined && userConfig.debug === undefined) {
|
|
110
|
+
newConfig.debug = anyAccess.debug;
|
|
111
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
112
|
+
console.warn('[SessionKit] Deprecation: debug should be at the top level of configuration, not inside "access".');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Set context hooks
|
|
118
|
+
newConfig.runWithContext = userConfig.runWithContext;
|
|
119
|
+
newConfig.getContextStore = userConfig.getContextStore;
|
|
120
|
+
newConfig.setContextStore = userConfig.setContextStore;
|
|
121
|
+
|
|
122
|
+
if (userConfig.globalProtect !== undefined) {
|
|
123
|
+
newConfig.globalProtect = userConfig.globalProtect;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (userConfig.exclude !== undefined) {
|
|
127
|
+
for (const pattern of userConfig.exclude) {
|
|
128
|
+
if (!isValidPattern(pattern)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`[SessionKit] Invalid exclude pattern: "${pattern}". ` +
|
|
131
|
+
`Patterns must start with / and be less than 1000 characters.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
newConfig.exclude = [...userConfig.exclude];
|
|
136
|
+
} else if (newConfig.exclude.length === 0) {
|
|
137
|
+
newConfig.exclude = [...DEFAULT_CONFIG.exclude];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (userConfig.debug !== undefined) {
|
|
141
|
+
newConfig.debug = userConfig.debug;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Atomic update
|
|
145
|
+
config = Object.freeze(newConfig);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get current configuration
|
|
150
|
+
*/
|
|
151
|
+
export function getConfig(): ResolvedConfig {
|
|
152
|
+
return config;
|
|
153
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Session Context (AsyncLocalStorage)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
import type { SessionContext } from "./types";
|
|
7
|
+
import { getConfig } from "./config";
|
|
8
|
+
|
|
9
|
+
const als = new AsyncLocalStorage<SessionContext>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run a function with session context available
|
|
13
|
+
*/
|
|
14
|
+
export function runWithContext<T>(
|
|
15
|
+
context: SessionContext,
|
|
16
|
+
fn: () => T
|
|
17
|
+
): T {
|
|
18
|
+
return als.run(context, fn);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the current session context (only available inside middleware chain)
|
|
23
|
+
*/
|
|
24
|
+
export function getContextStore(): SessionContext | undefined {
|
|
25
|
+
const customGetter = getConfig().getContextStore;
|
|
26
|
+
if (customGetter) {
|
|
27
|
+
return customGetter();
|
|
28
|
+
}
|
|
29
|
+
return als.getStore();
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|