@brika/auth 0.1.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 +207 -0
- package/package.json +50 -0
- package/src/__tests__/AuthClient.test.ts +736 -0
- package/src/__tests__/AuthService.test.ts +140 -0
- package/src/__tests__/ScopeService.test.ts +156 -0
- package/src/__tests__/SessionService.test.ts +311 -0
- package/src/__tests__/UserService-avatar.test.ts +277 -0
- package/src/__tests__/UserService.test.ts +223 -0
- package/src/__tests__/canAccess.test.ts +166 -0
- package/src/__tests__/disabledScopes.test.ts +101 -0
- package/src/__tests__/middleware.test.ts +190 -0
- package/src/__tests__/plugin.test.ts +78 -0
- package/src/__tests__/requireSession.test.ts +78 -0
- package/src/__tests__/routes-auth.test.ts +248 -0
- package/src/__tests__/routes-profile.test.ts +403 -0
- package/src/__tests__/routes-scopes.test.ts +64 -0
- package/src/__tests__/routes-sessions.test.ts +235 -0
- package/src/__tests__/routes-users.test.ts +477 -0
- package/src/__tests__/serveImage.test.ts +277 -0
- package/src/__tests__/setup.test.ts +270 -0
- package/src/__tests__/verifyToken.test.ts +219 -0
- package/src/client/AuthClient.ts +312 -0
- package/src/client/http-client.ts +84 -0
- package/src/client/index.ts +19 -0
- package/src/config.ts +82 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +16 -0
- package/src/lib/define-roles.ts +35 -0
- package/src/lib/define-scopes.ts +48 -0
- package/src/middleware/canAccess.ts +126 -0
- package/src/middleware/index.ts +13 -0
- package/src/middleware/requireAuth.ts +35 -0
- package/src/middleware/requireScope.ts +46 -0
- package/src/middleware/verifyToken.ts +52 -0
- package/src/plugin.ts +86 -0
- package/src/react/AuthProvider.tsx +105 -0
- package/src/react/hooks.ts +128 -0
- package/src/react/index.ts +51 -0
- package/src/react/withScopeGuard.tsx +73 -0
- package/src/roles.ts +40 -0
- package/src/schemas.ts +112 -0
- package/src/scopes.ts +60 -0
- package/src/server/index.ts +44 -0
- package/src/server/requireSession.ts +44 -0
- package/src/server/routes/auth.ts +102 -0
- package/src/server/routes/cookie.ts +7 -0
- package/src/server/routes/index.ts +32 -0
- package/src/server/routes/profile.ts +162 -0
- package/src/server/routes/scopes.ts +22 -0
- package/src/server/routes/sessions.ts +68 -0
- package/src/server/routes/setup.ts +50 -0
- package/src/server/routes/users.ts +175 -0
- package/src/server/serveImage.ts +91 -0
- package/src/services/AuthService.ts +80 -0
- package/src/services/ScopeService.ts +94 -0
- package/src/services/SessionService.ts +245 -0
- package/src/services/UserService.ts +245 -0
- package/src/setup.ts +99 -0
- package/src/tanstack/index.ts +15 -0
- package/src/tanstack/routeBuilder.ts +311 -0
- package/src/types.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/react - Hooks
|
|
3
|
+
*
|
|
4
|
+
* React hooks for authentication
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useContext, useMemo } from 'react';
|
|
8
|
+
import { canAccess, canAccessAll, Features } from '../middleware/canAccess';
|
|
9
|
+
import { Scope } from '../types';
|
|
10
|
+
import { AuthContext, AuthContextValue } from './AuthProvider';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Use authentication context
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const { user, login, logout } = useAuth();
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function useAuth(): AuthContextValue {
|
|
21
|
+
const context = useContext(AuthContext);
|
|
22
|
+
|
|
23
|
+
if (!context) {
|
|
24
|
+
throw new Error('useAuth must be used within <AuthProvider>');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return context;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if user has access to a scope
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* const canEdit = useCanAccess(Scope.WORKFLOW_WRITE);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function useCanAccess(required: Scope | Scope[] | null): boolean {
|
|
39
|
+
const { session } = useAuth();
|
|
40
|
+
|
|
41
|
+
return useMemo(() => {
|
|
42
|
+
if (!required || !session) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return canAccess((session.scopes || []) as Scope[], required);
|
|
46
|
+
}, [required, session?.scopes]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if user has all required scopes
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* const canManage = useCanAccessAll([Scope.ADMIN_ALL]);
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function useCanAccessAll(required: Scope[] | null): boolean {
|
|
58
|
+
const { session } = useAuth();
|
|
59
|
+
|
|
60
|
+
return useMemo(() => {
|
|
61
|
+
if (!required || !session) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return canAccessAll((session.scopes || []) as Scope[], required);
|
|
65
|
+
}, [required, session?.scopes]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get feature permissions
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```tsx
|
|
73
|
+
* const perms = useFeaturePermissions(Features.Workflow);
|
|
74
|
+
* if (perms.execute) {
|
|
75
|
+
* // show execute button
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function useFeaturePermissions<
|
|
80
|
+
T extends Record<string, boolean | ((scopes: Scope[]) => boolean)>,
|
|
81
|
+
>(featurePermissions: T): Record<keyof T, boolean> {
|
|
82
|
+
const { session } = useAuth();
|
|
83
|
+
|
|
84
|
+
return useMemo(() => {
|
|
85
|
+
const scopes = (session?.scopes ?? []) as Scope[];
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
Object.entries(featurePermissions).map(([key, checker]) => {
|
|
88
|
+
if (!session) {
|
|
89
|
+
return [key, false];
|
|
90
|
+
}
|
|
91
|
+
const value = typeof checker === 'function' ? checker(scopes) : checker;
|
|
92
|
+
return [key, value];
|
|
93
|
+
})
|
|
94
|
+
) as Record<keyof T, boolean>;
|
|
95
|
+
}, [session?.scopes]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if user is loading auth state
|
|
100
|
+
*/
|
|
101
|
+
export function useAuthLoading(): boolean {
|
|
102
|
+
const { isLoading } = useAuth();
|
|
103
|
+
return isLoading;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get current user
|
|
108
|
+
*/
|
|
109
|
+
export function useUser() {
|
|
110
|
+
const { user } = useAuth();
|
|
111
|
+
return user;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get current session
|
|
116
|
+
*/
|
|
117
|
+
export function useSession() {
|
|
118
|
+
const { session } = useAuth();
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get auth error
|
|
124
|
+
*/
|
|
125
|
+
export function useAuthError() {
|
|
126
|
+
const { error } = useAuth();
|
|
127
|
+
return error;
|
|
128
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/react
|
|
3
|
+
*
|
|
4
|
+
* React hooks and components for authentication.
|
|
5
|
+
* Use this in your Brika UI React app.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { AuthProvider, useAuth } from '@brika/auth/react';
|
|
10
|
+
*
|
|
11
|
+
* function App() {
|
|
12
|
+
* return (
|
|
13
|
+
* // No apiUrl needed - uses window.location.origin
|
|
14
|
+
* <AuthProvider>
|
|
15
|
+
* <Dashboard />
|
|
16
|
+
* </AuthProvider>
|
|
17
|
+
* );
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* function Dashboard() {
|
|
21
|
+
* const { user, login, logout } = useAuth();
|
|
22
|
+
* const canEdit = useCanAccess(Scope.WORKFLOW_WRITE);
|
|
23
|
+
*
|
|
24
|
+
* return (
|
|
25
|
+
* <>
|
|
26
|
+
* <h1>Hello, {user?.name}!</h1>
|
|
27
|
+
* {canEdit && <EditButton />}
|
|
28
|
+
* <button onClick={logout}>Logout</button>
|
|
29
|
+
* </>
|
|
30
|
+
* );
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
AuthContext,
|
|
37
|
+
type AuthContextValue,
|
|
38
|
+
AuthProvider,
|
|
39
|
+
type AuthProviderProps,
|
|
40
|
+
} from './AuthProvider';
|
|
41
|
+
export {
|
|
42
|
+
useAuth,
|
|
43
|
+
useAuthError,
|
|
44
|
+
useAuthLoading,
|
|
45
|
+
useCanAccess,
|
|
46
|
+
useCanAccessAll,
|
|
47
|
+
useFeaturePermissions,
|
|
48
|
+
useSession,
|
|
49
|
+
useUser,
|
|
50
|
+
} from './hooks';
|
|
51
|
+
export { type WithScopeGuardOptions, withOptionalScope, withScopeGuard } from './withScopeGuard';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/react - withScopeGuard HOC
|
|
3
|
+
*
|
|
4
|
+
* Higher-order component to protect components with scope requirements.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { Scope } from '../types';
|
|
9
|
+
import { useCanAccess } from './hooks';
|
|
10
|
+
|
|
11
|
+
export interface WithScopeGuardOptions {
|
|
12
|
+
fallback?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_FALLBACK = (
|
|
16
|
+
<div>
|
|
17
|
+
<h1>Unauthorized</h1>
|
|
18
|
+
<p>You don't have permission to access this page.</p>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* HOC to protect a component with scope requirement.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const ProtectedEditor = withScopeGuard(
|
|
28
|
+
* WorkflowEditor,
|
|
29
|
+
* Scope.WORKFLOW_WRITE,
|
|
30
|
+
* { fallback: <UnauthorizedPage /> }
|
|
31
|
+
* );
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function withScopeGuard<P extends object>(
|
|
35
|
+
Component: React.ComponentType<P>,
|
|
36
|
+
requiredScopes: Scope | Scope[] | null,
|
|
37
|
+
options?: WithScopeGuardOptions
|
|
38
|
+
): React.ComponentType<P> {
|
|
39
|
+
const displayName = `withScopeGuard(${Component.displayName || Component.name})`;
|
|
40
|
+
|
|
41
|
+
const Wrapper = (props: P) => {
|
|
42
|
+
const canAccess = useCanAccess(requiredScopes);
|
|
43
|
+
|
|
44
|
+
if (!canAccess) {
|
|
45
|
+
return options?.fallback !== undefined ? options.fallback : DEFAULT_FALLBACK;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return <Component {...props} />;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
Wrapper.displayName = displayName;
|
|
52
|
+
|
|
53
|
+
return Wrapper;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* HOC to hide component if user lacks scope
|
|
58
|
+
* (renders nothing instead of fallback)
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const OptionalButton = withOptionalScope(AdminButton, Scope.ADMIN_ALL);
|
|
63
|
+
* // Returns null if user doesn't have admin scope
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function withOptionalScope<P extends object>(
|
|
67
|
+
Component: React.ComponentType<P>,
|
|
68
|
+
requiredScopes: Scope | Scope[] | null
|
|
69
|
+
): React.ComponentType<P> {
|
|
70
|
+
return withScopeGuard(Component, requiredScopes, {
|
|
71
|
+
fallback: null,
|
|
72
|
+
});
|
|
73
|
+
}
|
package/src/roles.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Roles
|
|
3
|
+
*
|
|
4
|
+
* User roles determine the default set of scopes granted to a user.
|
|
5
|
+
*
|
|
6
|
+
* To add a new role:
|
|
7
|
+
* 1. Add an entry below with its default scopes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineRoles } from './lib/define-roles';
|
|
11
|
+
import { Scope } from './scopes';
|
|
12
|
+
|
|
13
|
+
export const { Role, ROLE_SCOPES } = defineRoles({
|
|
14
|
+
ADMIN: {
|
|
15
|
+
value: 'admin',
|
|
16
|
+
defaultScopes: [Scope.ADMIN_ALL],
|
|
17
|
+
},
|
|
18
|
+
USER: {
|
|
19
|
+
value: 'user',
|
|
20
|
+
defaultScopes: [
|
|
21
|
+
Scope.WORKFLOW_READ,
|
|
22
|
+
Scope.WORKFLOW_WRITE,
|
|
23
|
+
Scope.WORKFLOW_EXECUTE,
|
|
24
|
+
Scope.BOARD_READ,
|
|
25
|
+
Scope.BOARD_WRITE,
|
|
26
|
+
Scope.PLUGIN_READ,
|
|
27
|
+
Scope.SETTINGS_READ,
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
GUEST: {
|
|
31
|
+
value: 'guest',
|
|
32
|
+
defaultScopes: [Scope.WORKFLOW_READ, Scope.BOARD_READ, Scope.PLUGIN_READ],
|
|
33
|
+
},
|
|
34
|
+
SERVICE: {
|
|
35
|
+
value: 'service',
|
|
36
|
+
defaultScopes: [],
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type Role = (typeof Role)[keyof typeof Role];
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Zod Schemas for Validation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { getAuthConfig } from './config';
|
|
7
|
+
import { Role } from './roles';
|
|
8
|
+
import { Scope } from './scopes';
|
|
9
|
+
|
|
10
|
+
const roleValues = Object.values(Role) as [Role, ...Role[]];
|
|
11
|
+
const scopeValues = Object.values(Scope) as [Scope, ...Scope[]];
|
|
12
|
+
|
|
13
|
+
export const RoleSchema = z.enum(roleValues);
|
|
14
|
+
|
|
15
|
+
export const ScopeSchema = z.enum(scopeValues);
|
|
16
|
+
|
|
17
|
+
export const EmailSchema = z.string().email('Invalid email address').toLowerCase();
|
|
18
|
+
|
|
19
|
+
export const NameSchema = z.string().min(2, 'Name must be at least 2 characters').max(255);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Password schema — reads config lazily so it respects runtime overrides.
|
|
23
|
+
* Uses z.string() + superRefine so all rules are evaluated at validation time.
|
|
24
|
+
* Max 72 chars: bcrypt silently truncates beyond this, so enforce it explicitly.
|
|
25
|
+
*/
|
|
26
|
+
export const PasswordSchema = z
|
|
27
|
+
.string()
|
|
28
|
+
.max(72, 'Max 72 characters')
|
|
29
|
+
.superRefine((v, ctx) => {
|
|
30
|
+
const { password } = getAuthConfig();
|
|
31
|
+
|
|
32
|
+
if (v.length < password.minLength) {
|
|
33
|
+
ctx.addIssue({
|
|
34
|
+
code: 'custom',
|
|
35
|
+
message: `Min ${password.minLength} characters`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (password.requireUppercase && !/[A-Z]/.test(v)) {
|
|
39
|
+
ctx.addIssue({
|
|
40
|
+
code: 'custom',
|
|
41
|
+
message: 'Need uppercase letter (A-Z)',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (password.requireNumbers && !/\d/.test(v)) {
|
|
45
|
+
ctx.addIssue({
|
|
46
|
+
code: 'custom',
|
|
47
|
+
message: 'Need number (0-9)',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (password.requireSpecial && !password.specialChars.test(v)) {
|
|
51
|
+
ctx.addIssue({
|
|
52
|
+
code: 'custom',
|
|
53
|
+
message: 'Need special character (!@#$%^&*...)',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const UserSchema = z.object({
|
|
59
|
+
id: z.string().uuid(),
|
|
60
|
+
email: EmailSchema,
|
|
61
|
+
name: NameSchema,
|
|
62
|
+
role: RoleSchema,
|
|
63
|
+
createdAt: z.date(),
|
|
64
|
+
updatedAt: z.date(),
|
|
65
|
+
isActive: z.boolean().default(true),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const SessionSchema = z.object({
|
|
69
|
+
id: z.string(),
|
|
70
|
+
userId: z.string(),
|
|
71
|
+
userEmail: z.string().email(),
|
|
72
|
+
userName: z.string(),
|
|
73
|
+
userRole: RoleSchema,
|
|
74
|
+
scopes: z.array(ScopeSchema),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const LoginSchema = z.object({
|
|
78
|
+
email: EmailSchema,
|
|
79
|
+
password: z.string().min(1),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const CreateUserSchema = z.object({
|
|
83
|
+
email: EmailSchema,
|
|
84
|
+
name: NameSchema,
|
|
85
|
+
role: RoleSchema.default(Role.USER),
|
|
86
|
+
password: PasswordSchema.optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const SetupSchema = z.object({
|
|
90
|
+
email: EmailSchema,
|
|
91
|
+
name: NameSchema,
|
|
92
|
+
password: PasswordSchema,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const CreateApiTokenSchema = z.object({
|
|
96
|
+
name: z.string().min(1).max(255),
|
|
97
|
+
scopes: z.array(ScopeSchema),
|
|
98
|
+
expiresAt: z.date().optional(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate a password against the policy.
|
|
103
|
+
* Returns the first error message, or undefined if valid.
|
|
104
|
+
* Compatible with @clack/prompts validate functions.
|
|
105
|
+
*/
|
|
106
|
+
export function validatePassword(value: string): string | undefined {
|
|
107
|
+
const result = PasswordSchema.safeParse(value);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return result.error.issues[0]?.message;
|
|
112
|
+
}
|
package/src/scopes.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Scopes
|
|
3
|
+
*
|
|
4
|
+
* Permission scopes control what actions a user can perform.
|
|
5
|
+
* Each user has an explicit allow-list of scopes stored in DB.
|
|
6
|
+
* New users receive ROLE_SCOPES[role] as defaults.
|
|
7
|
+
*
|
|
8
|
+
* To add a new scope:
|
|
9
|
+
* 1. Add an entry to the scopes object below
|
|
10
|
+
* 2. Optionally add it to ROLE_SCOPES in role-scopes.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { defineScopes } from './lib/define-scopes';
|
|
14
|
+
|
|
15
|
+
export const { Scope, SCOPES_REGISTRY } = defineScopes({
|
|
16
|
+
scopes: {
|
|
17
|
+
ADMIN_ALL: {
|
|
18
|
+
value: 'admin:*',
|
|
19
|
+
description: 'Full administrative access',
|
|
20
|
+
},
|
|
21
|
+
WORKFLOW_READ: {
|
|
22
|
+
value: 'workflow:read',
|
|
23
|
+
description: 'Read workflows',
|
|
24
|
+
},
|
|
25
|
+
WORKFLOW_WRITE: {
|
|
26
|
+
value: 'workflow:write',
|
|
27
|
+
description: 'Create and edit workflows',
|
|
28
|
+
},
|
|
29
|
+
WORKFLOW_EXECUTE: {
|
|
30
|
+
value: 'workflow:execute',
|
|
31
|
+
description: 'Execute workflows',
|
|
32
|
+
},
|
|
33
|
+
BOARD_READ: {
|
|
34
|
+
value: 'board:read',
|
|
35
|
+
description: 'Read boards',
|
|
36
|
+
},
|
|
37
|
+
BOARD_WRITE: {
|
|
38
|
+
value: 'board:write',
|
|
39
|
+
description: 'Create and edit boards',
|
|
40
|
+
},
|
|
41
|
+
PLUGIN_READ: {
|
|
42
|
+
value: 'plugin:read',
|
|
43
|
+
description: 'List and read plugins',
|
|
44
|
+
},
|
|
45
|
+
PLUGIN_MANAGE: {
|
|
46
|
+
value: 'plugin:manage',
|
|
47
|
+
description: 'Install and uninstall plugins',
|
|
48
|
+
},
|
|
49
|
+
SETTINGS_READ: {
|
|
50
|
+
value: 'settings:read',
|
|
51
|
+
description: 'Read system settings',
|
|
52
|
+
},
|
|
53
|
+
SETTINGS_WRITE: {
|
|
54
|
+
value: 'settings:write',
|
|
55
|
+
description: 'Modify system settings',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type Scope = (typeof Scope)[keyof typeof Scope];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/server
|
|
3
|
+
*
|
|
4
|
+
* Server-side authentication module.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { auth } from '@brika/auth/server';
|
|
9
|
+
*
|
|
10
|
+
* // Hub: with API server
|
|
11
|
+
* await bootstrap()
|
|
12
|
+
* .use(auth({ dataDir, server: inject(ApiServer) }))
|
|
13
|
+
* .start();
|
|
14
|
+
*
|
|
15
|
+
* // CLI: services + DB only
|
|
16
|
+
* await bootstrapCLI()
|
|
17
|
+
* .use(auth({ dataDir }))
|
|
18
|
+
* .start();
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
canAccess,
|
|
24
|
+
canAccessAll,
|
|
25
|
+
createPermissionChecker,
|
|
26
|
+
Features,
|
|
27
|
+
} from '../middleware/canAccess';
|
|
28
|
+
// 🛡️ Middleware
|
|
29
|
+
export { type AuthContext, requireAuth } from '../middleware/requireAuth';
|
|
30
|
+
export { requireScope } from '../middleware/requireScope';
|
|
31
|
+
export { verifyToken } from '../middleware/verifyToken';
|
|
32
|
+
export { auth } from '../plugin';
|
|
33
|
+
// 🏗️ Services
|
|
34
|
+
export { AuthService } from '../services/AuthService';
|
|
35
|
+
export { ScopeService } from '../services/ScopeService';
|
|
36
|
+
export { SessionService } from '../services/SessionService';
|
|
37
|
+
export { UserService } from '../services/UserService';
|
|
38
|
+
// 🎯 Setup & Plugin
|
|
39
|
+
export { openAuthDatabase, setupAuthServices } from '../setup';
|
|
40
|
+
// 🛠️ Route Helpers
|
|
41
|
+
export { requireSession } from './requireSession';
|
|
42
|
+
// 🌐 Routes
|
|
43
|
+
export { allAuthRoutes as authRoutes } from './routes/index';
|
|
44
|
+
export { serveImage } from './serveImage';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/server - requireSession
|
|
3
|
+
*
|
|
4
|
+
* Extract authenticated session from route context.
|
|
5
|
+
* Throws 401 Unauthorized if no session.
|
|
6
|
+
* Optionally validates scope — throws 403 Forbidden if missing required scope.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* // Just auth check
|
|
11
|
+
* const session = requireSession(ctx);
|
|
12
|
+
*
|
|
13
|
+
* // Auth + scope check (throws 403 if missing)
|
|
14
|
+
* const session = requireSession(ctx, Scope.ADMIN_ALL);
|
|
15
|
+
*
|
|
16
|
+
* // Auth + any-of scope check
|
|
17
|
+
* const session = requireSession(ctx, [Scope.WORKFLOW_READ, Scope.ADMIN_ALL]);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Forbidden, Unauthorized } from '@brika/router';
|
|
22
|
+
import { canAccess } from '../middleware/canAccess';
|
|
23
|
+
import type { Scope, Session } from '../types';
|
|
24
|
+
|
|
25
|
+
export function requireSession(
|
|
26
|
+
ctx: {
|
|
27
|
+
get(key: string): unknown;
|
|
28
|
+
},
|
|
29
|
+
scope?: Scope | Scope[]
|
|
30
|
+
): Session {
|
|
31
|
+
const session = ctx.get('session') as Session | null;
|
|
32
|
+
if (!session) {
|
|
33
|
+
throw new Unauthorized();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (scope !== undefined) {
|
|
37
|
+
if (!canAccess(session.scopes, scope)) {
|
|
38
|
+
const required = Array.isArray(scope) ? scope : [scope];
|
|
39
|
+
throw new Forbidden(`Insufficient permissions. Required: ${required.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return session;
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth routes — login, logout, session info
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { inject } from '@brika/di';
|
|
6
|
+
import { rateLimit, route } from '@brika/router';
|
|
7
|
+
import { LoginSchema } from '../../schemas';
|
|
8
|
+
import { AuthService } from '../../services/AuthService';
|
|
9
|
+
import { UserService } from '../../services/UserService';
|
|
10
|
+
import type { Session } from '../../types';
|
|
11
|
+
import { requireSession } from '../requireSession';
|
|
12
|
+
import { sessionCookie } from './cookie';
|
|
13
|
+
|
|
14
|
+
/** POST /login — Login with email and password */
|
|
15
|
+
const login = route.post({
|
|
16
|
+
path: '/login',
|
|
17
|
+
middleware: [
|
|
18
|
+
rateLimit({
|
|
19
|
+
window: 60,
|
|
20
|
+
max: 5,
|
|
21
|
+
}),
|
|
22
|
+
],
|
|
23
|
+
body: LoginSchema,
|
|
24
|
+
handler: async (ctx) => {
|
|
25
|
+
const authService = inject(AuthService);
|
|
26
|
+
const { email, password } = ctx.body;
|
|
27
|
+
const ip =
|
|
28
|
+
ctx.req.headers.get('x-forwarded-for') ?? ctx.req.headers.get('x-real-ip') ?? undefined;
|
|
29
|
+
const userAgent = ctx.req.headers.get('user-agent') ?? undefined;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const result = await authService.login(email, password, ip, userAgent);
|
|
33
|
+
|
|
34
|
+
return new Response(
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
user: result.user,
|
|
37
|
+
}),
|
|
38
|
+
{
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'Set-Cookie': sessionCookie(result.token, result.expiresIn),
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
return new Response(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
error: 'Invalid credentials',
|
|
49
|
+
}),
|
|
50
|
+
{
|
|
51
|
+
status: 401,
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** POST /logout — Logout and revoke current session */
|
|
62
|
+
const logout = route.post({
|
|
63
|
+
path: '/logout',
|
|
64
|
+
handler: (ctx) => {
|
|
65
|
+
const authService = inject(AuthService);
|
|
66
|
+
const session = ctx.get('session') as Session | null;
|
|
67
|
+
|
|
68
|
+
if (session) {
|
|
69
|
+
authService.logout(session.id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return new Response(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
ok: true,
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Set-Cookie': sessionCookie('', 0),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** GET /session — Get current session info */
|
|
87
|
+
const sessionInfo = route.get({
|
|
88
|
+
path: '/session',
|
|
89
|
+
handler: (ctx) => {
|
|
90
|
+
const session = requireSession(ctx);
|
|
91
|
+
const userService = inject(UserService);
|
|
92
|
+
const user = userService.getUser(session.userId);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
user,
|
|
96
|
+
scopes: session.scopes,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const authPublicRoutes = [login];
|
|
102
|
+
export const authProtectedRoutes = [logout, sessionInfo];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getAuthConfig } from '../../config';
|
|
2
|
+
|
|
3
|
+
/** Build a Set-Cookie header value for the session token. */
|
|
4
|
+
export function sessionCookie(token: string, maxAge: number): string {
|
|
5
|
+
const name = getAuthConfig().session.cookieName;
|
|
6
|
+
return `${name}=${token}; HttpOnly; Secure; Path=/api; Max-Age=${maxAge}; SameSite=Lax`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/server - All Routes
|
|
3
|
+
*
|
|
4
|
+
* Combines auth, session, profile, user, and scope routes.
|
|
5
|
+
* Login and scopes are public; everything else requires authentication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { combineRoutes, group } from '@brika/router';
|
|
9
|
+
import { requireAuth } from '../../middleware/requireAuth';
|
|
10
|
+
import { authProtectedRoutes, authPublicRoutes } from './auth';
|
|
11
|
+
import { profileRoutes } from './profile';
|
|
12
|
+
import { scopeRoutes } from './scopes';
|
|
13
|
+
import { sessionRoutes } from './sessions';
|
|
14
|
+
import { setupRoutes } from './setup';
|
|
15
|
+
import { userRoutes } from './users';
|
|
16
|
+
|
|
17
|
+
export const allAuthRoutes = combineRoutes(
|
|
18
|
+
group({
|
|
19
|
+
prefix: '/api/auth',
|
|
20
|
+
routes: [authPublicRoutes, scopeRoutes],
|
|
21
|
+
}),
|
|
22
|
+
group({
|
|
23
|
+
prefix: '/api/auth/setup',
|
|
24
|
+
routes: [setupRoutes],
|
|
25
|
+
}),
|
|
26
|
+
group({
|
|
27
|
+
prefix: '/api/auth',
|
|
28
|
+
middleware: [requireAuth()],
|
|
29
|
+
routes: [authProtectedRoutes, sessionRoutes, profileRoutes],
|
|
30
|
+
}),
|
|
31
|
+
userRoutes
|
|
32
|
+
);
|