@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.
Files changed (62) hide show
  1. package/README.md +207 -0
  2. package/package.json +50 -0
  3. package/src/__tests__/AuthClient.test.ts +736 -0
  4. package/src/__tests__/AuthService.test.ts +140 -0
  5. package/src/__tests__/ScopeService.test.ts +156 -0
  6. package/src/__tests__/SessionService.test.ts +311 -0
  7. package/src/__tests__/UserService-avatar.test.ts +277 -0
  8. package/src/__tests__/UserService.test.ts +223 -0
  9. package/src/__tests__/canAccess.test.ts +166 -0
  10. package/src/__tests__/disabledScopes.test.ts +101 -0
  11. package/src/__tests__/middleware.test.ts +190 -0
  12. package/src/__tests__/plugin.test.ts +78 -0
  13. package/src/__tests__/requireSession.test.ts +78 -0
  14. package/src/__tests__/routes-auth.test.ts +248 -0
  15. package/src/__tests__/routes-profile.test.ts +403 -0
  16. package/src/__tests__/routes-scopes.test.ts +64 -0
  17. package/src/__tests__/routes-sessions.test.ts +235 -0
  18. package/src/__tests__/routes-users.test.ts +477 -0
  19. package/src/__tests__/serveImage.test.ts +277 -0
  20. package/src/__tests__/setup.test.ts +270 -0
  21. package/src/__tests__/verifyToken.test.ts +219 -0
  22. package/src/client/AuthClient.ts +312 -0
  23. package/src/client/http-client.ts +84 -0
  24. package/src/client/index.ts +19 -0
  25. package/src/config.ts +82 -0
  26. package/src/constants.ts +10 -0
  27. package/src/index.ts +16 -0
  28. package/src/lib/define-roles.ts +35 -0
  29. package/src/lib/define-scopes.ts +48 -0
  30. package/src/middleware/canAccess.ts +126 -0
  31. package/src/middleware/index.ts +13 -0
  32. package/src/middleware/requireAuth.ts +35 -0
  33. package/src/middleware/requireScope.ts +46 -0
  34. package/src/middleware/verifyToken.ts +52 -0
  35. package/src/plugin.ts +86 -0
  36. package/src/react/AuthProvider.tsx +105 -0
  37. package/src/react/hooks.ts +128 -0
  38. package/src/react/index.ts +51 -0
  39. package/src/react/withScopeGuard.tsx +73 -0
  40. package/src/roles.ts +40 -0
  41. package/src/schemas.ts +112 -0
  42. package/src/scopes.ts +60 -0
  43. package/src/server/index.ts +44 -0
  44. package/src/server/requireSession.ts +44 -0
  45. package/src/server/routes/auth.ts +102 -0
  46. package/src/server/routes/cookie.ts +7 -0
  47. package/src/server/routes/index.ts +32 -0
  48. package/src/server/routes/profile.ts +162 -0
  49. package/src/server/routes/scopes.ts +22 -0
  50. package/src/server/routes/sessions.ts +68 -0
  51. package/src/server/routes/setup.ts +50 -0
  52. package/src/server/routes/users.ts +175 -0
  53. package/src/server/serveImage.ts +91 -0
  54. package/src/services/AuthService.ts +80 -0
  55. package/src/services/ScopeService.ts +94 -0
  56. package/src/services/SessionService.ts +245 -0
  57. package/src/services/UserService.ts +245 -0
  58. package/src/setup.ts +99 -0
  59. package/src/tanstack/index.ts +15 -0
  60. package/src/tanstack/routeBuilder.ts +311 -0
  61. package/src/types.ts +118 -0
  62. package/tsconfig.json +8 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @brika/auth - Constants
3
+ *
4
+ * Re-exports for backward compatibility.
5
+ * Canonical homes: scopes.ts, config.ts
6
+ */
7
+
8
+ export { AUTH_DEFAULTS, getAuthConfig } from './config';
9
+ export { ROLE_SCOPES } from './roles';
10
+ export { SCOPES_REGISTRY } from './scopes';
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @brika/auth
3
+ *
4
+ * Shared types and constants for authentication system.
5
+ *
6
+ * For server-side: import { ... } from '@brika/auth/server'
7
+ * For client-side: import { ... } from '@brika/auth/client'
8
+ * For React: import { ... } from '@brika/auth/react'
9
+ */
10
+
11
+ export * from './config';
12
+ export * from './constants';
13
+ export * from './roles';
14
+ export * from './schemas';
15
+ export * from './scopes';
16
+ export * from './types';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @brika/auth - Role builder
3
+ *
4
+ * Takes a declarative role config and produces typed Role constant
5
+ * and ROLE_SCOPES mapping.
6
+ */
7
+
8
+ interface RoleDef<TScope extends string> {
9
+ readonly value: string;
10
+ readonly defaultScopes: readonly TScope[];
11
+ }
12
+
13
+ export interface BuiltRoles<TDefs extends Record<string, RoleDef<string>>> {
14
+ /** Role constant — e.g. `Role.ADMIN` → `'admin'` */
15
+ Role: { readonly [K in keyof TDefs]: TDefs[K]['value'] };
16
+ /** Default scopes for each role (keyed by role value). */
17
+ ROLE_SCOPES: { [K in keyof TDefs as TDefs[K]['value']]: TDefs[K]['defaultScopes'][number][] };
18
+ }
19
+
20
+ export function defineRoles<const TDefs extends Record<string, RoleDef<string>>>(
21
+ defs: TDefs
22
+ ): BuiltRoles<TDefs> {
23
+ const Role = Object.fromEntries(
24
+ Object.entries(defs).map(([k, v]) => [k, v.value])
25
+ ) as BuiltRoles<TDefs>['Role'];
26
+
27
+ const ROLE_SCOPES = Object.fromEntries(
28
+ Object.entries(defs).map(([, v]) => [v.value, [...v.defaultScopes]])
29
+ ) as BuiltRoles<TDefs>['ROLE_SCOPES'];
30
+
31
+ return {
32
+ Role,
33
+ ROLE_SCOPES,
34
+ };
35
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @brika/auth - Scope builder
3
+ *
4
+ * Takes a declarative scope/role config and produces typed Scope constant,
5
+ * ROLE_SCOPES mapping, and SCOPES_REGISTRY metadata.
6
+ */
7
+
8
+ interface ScopeDef {
9
+ readonly value: string;
10
+ readonly description: string;
11
+ }
12
+
13
+ export interface ScopeRegistryEntry {
14
+ description: string;
15
+ category: string;
16
+ }
17
+
18
+ export interface BuiltScopes<TDefs extends Record<string, ScopeDef>> {
19
+ /** Scope constant — e.g. `Scope.WORKFLOW_READ` → `'workflow:read'` */
20
+ Scope: { readonly [K in keyof TDefs]: TDefs[K]['value'] };
21
+ /** Scope metadata for UI display. */
22
+ SCOPES_REGISTRY: Record<TDefs[keyof TDefs]['value'], ScopeRegistryEntry>;
23
+ }
24
+
25
+ export function defineScopes<const TDefs extends Record<string, ScopeDef>>(config: {
26
+ scopes: TDefs;
27
+ }): BuiltScopes<TDefs> {
28
+ const { scopes } = config;
29
+
30
+ const Scope = Object.fromEntries(
31
+ Object.entries(scopes).map(([k, v]) => [k, v.value])
32
+ ) as BuiltScopes<TDefs>['Scope'];
33
+
34
+ const SCOPES_REGISTRY = Object.fromEntries(
35
+ Object.values(scopes).map((def) => [
36
+ def.value,
37
+ {
38
+ description: def.description,
39
+ category: def.value.split(':')[0],
40
+ },
41
+ ])
42
+ ) as BuiltScopes<TDefs>['SCOPES_REGISTRY'];
43
+
44
+ return {
45
+ Scope,
46
+ SCOPES_REGISTRY,
47
+ };
48
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @brika/auth - canAccess Helper
3
+ * Check scope/permission without throwing (returns boolean)
4
+ */
5
+
6
+ import { Scope } from '../types';
7
+
8
+ /**
9
+ * Check if session/scopes can access a feature
10
+ * Returns boolean (safe for use in UI logic, conditionals, etc)
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const canEditWorkflow = canAccess(session.scopes, Scope.WORKFLOW_WRITE);
15
+ * if (canEditWorkflow) {
16
+ * renderEditButton();
17
+ * }
18
+ *
19
+ * // In routes
20
+ * if (!canAccess(session.scopes, [Scope.ADMIN_ALL])) {
21
+ * return forbidden();
22
+ * }
23
+ * ```
24
+ */
25
+ export function canAccess(scopes: Scope[] | null | undefined, required: Scope | Scope[]): boolean {
26
+ if (!scopes || scopes.length === 0) {
27
+ return false;
28
+ }
29
+
30
+ const requiredScopes = Array.isArray(required) ? required : [required];
31
+
32
+ // Admin can access everything
33
+ if (scopes.includes(Scope.ADMIN_ALL)) {
34
+ return true;
35
+ }
36
+
37
+ // Check if has any required scope
38
+ return requiredScopes.some((scope) => scopes.includes(scope));
39
+ }
40
+
41
+ /**
42
+ * Check if session can access all required scopes
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const canManageUsers = canAccessAll(session.scopes, [
47
+ * Scope.ADMIN_ALL,
48
+ * ]);
49
+ * ```
50
+ */
51
+ export function canAccessAll(scopes: Scope[] | null | undefined, required: Scope[]): boolean {
52
+ if (!scopes || scopes.length === 0) {
53
+ return false;
54
+ }
55
+
56
+ // Admin can access everything
57
+ if (scopes.includes(Scope.ADMIN_ALL)) {
58
+ return true;
59
+ }
60
+
61
+ // Check if has all required scopes
62
+ return required.every((scope) => scopes.includes(scope));
63
+ }
64
+
65
+ /**
66
+ * Create a permission checker for a specific feature
67
+ * Useful for organizing feature flags
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const WorkflowPermissions = {
72
+ * read: (scopes) => canAccess(scopes, Scope.WORKFLOW_READ),
73
+ * write: (scopes) => canAccess(scopes, Scope.WORKFLOW_WRITE),
74
+ * execute: (scopes) => canAccess(scopes, Scope.WORKFLOW_EXECUTE),
75
+ * };
76
+ *
77
+ * // Usage
78
+ * if (WorkflowPermissions.execute(session.scopes)) {
79
+ * canRunWorkflow = true;
80
+ * }
81
+ * ```
82
+ */
83
+ export function createPermissionChecker(
84
+ featureName: string,
85
+ scopeMap: Record<string, Scope | Scope[]>
86
+ ): Record<string, (scopes: Scope[] | null | undefined) => boolean> {
87
+ return Object.entries(scopeMap).reduce(
88
+ (acc, [action, scopes]) => {
89
+ acc[action] = (userScopes: Scope[] | null | undefined) => canAccess(userScopes, scopes);
90
+ return acc;
91
+ },
92
+ {} as Record<string, (scopes: Scope[] | null | undefined) => boolean>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Feature permission objects (pre-built for common features)
98
+ */
99
+ export const Features = {
100
+ Workflow: createPermissionChecker('Workflow', {
101
+ read: Scope.WORKFLOW_READ,
102
+ write: Scope.WORKFLOW_WRITE,
103
+ execute: Scope.WORKFLOW_EXECUTE,
104
+ all: [Scope.WORKFLOW_READ, Scope.WORKFLOW_WRITE, Scope.WORKFLOW_EXECUTE],
105
+ }),
106
+
107
+ Board: createPermissionChecker('Board', {
108
+ read: Scope.BOARD_READ,
109
+ write: Scope.BOARD_WRITE,
110
+ all: [Scope.BOARD_READ, Scope.BOARD_WRITE],
111
+ }),
112
+
113
+ Plugin: createPermissionChecker('Plugin', {
114
+ read: Scope.PLUGIN_READ,
115
+ manage: Scope.PLUGIN_MANAGE,
116
+ }),
117
+
118
+ Settings: createPermissionChecker('Settings', {
119
+ read: Scope.SETTINGS_READ,
120
+ write: Scope.SETTINGS_WRITE,
121
+ }),
122
+
123
+ Admin: createPermissionChecker('Admin', {
124
+ all: Scope.ADMIN_ALL,
125
+ }),
126
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @brika/auth - Middleware
3
+ */
4
+
5
+ export {
6
+ canAccess,
7
+ canAccessAll,
8
+ createPermissionChecker,
9
+ Features,
10
+ } from './canAccess';
11
+ export { type AuthContext, requireAuth } from './requireAuth';
12
+ export { requireScope } from './requireScope';
13
+ export { verifyToken } from './verifyToken';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @brika/auth - requireAuth Middleware
3
+ * Enforce authentication (block unauthenticated requests)
4
+ */
5
+
6
+ import type { HonoContext, Middleware } from '@brika/router';
7
+ import { Session } from '../types';
8
+
9
+ /**
10
+ * Hono middleware to enforce authentication.
11
+ * Returns 401 if no valid session.
12
+ */
13
+ export function requireAuth(): Middleware {
14
+ return async (context: HonoContext, next: () => Promise<void>) => {
15
+ const session = context.get('session');
16
+
17
+ if (!session) {
18
+ return context.json(
19
+ {
20
+ error: 'Unauthorized',
21
+ },
22
+ 401
23
+ );
24
+ }
25
+
26
+ await next();
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Type for authenticated context
32
+ */
33
+ export interface AuthContext {
34
+ session: Session;
35
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @brika/auth - requireScope Middleware
3
+ * Enforce scope/permission requirements (throw 403 if missing)
4
+ */
5
+
6
+ import { inject } from '@brika/di';
7
+ import type { HonoContext, Middleware } from '@brika/router';
8
+ import { ScopeService } from '../services/ScopeService';
9
+ import { Scope, Session } from '../types';
10
+
11
+ /**
12
+ * Hono middleware to enforce scope requirements.
13
+ * Returns 403 Forbidden if user lacks required scopes.
14
+ */
15
+ export function requireScope(required: Scope | Scope[]): Middleware {
16
+ const scopeService = inject(ScopeService);
17
+
18
+ return async (context: HonoContext, next: () => Promise<void>) => {
19
+ const session = context.get('session') as Session | null;
20
+
21
+ if (!session) {
22
+ return context.json(
23
+ {
24
+ error: 'unauthorized',
25
+ message: 'Authentication required',
26
+ },
27
+ 401
28
+ );
29
+ }
30
+
31
+ const requiredScopes = Array.isArray(required) ? required : [required];
32
+ const hasScope = requiredScopes.some((scope) => scopeService.hasScope(session.scopes, scope));
33
+
34
+ if (!hasScope) {
35
+ return context.json(
36
+ {
37
+ error: 'insufficient_permissions',
38
+ message: 'This operation requires additional permissions',
39
+ },
40
+ 403
41
+ );
42
+ }
43
+
44
+ await next();
45
+ };
46
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @brika/auth - verifyToken Middleware
3
+ * Extract session token from cookie (or Authorization header) and validate against DB.
4
+ */
5
+
6
+ import { inject } from '@brika/di';
7
+ import type { HonoContext, Middleware } from '@brika/router';
8
+ import { getAuthConfig } from '../config';
9
+ import { SessionService } from '../services/SessionService';
10
+
11
+ function getCookieValue(header: string | undefined, name: string): string | undefined {
12
+ if (!header) {
13
+ return undefined;
14
+ }
15
+ for (const pair of header.split(';')) {
16
+ const [key, ...rest] = pair.split('=');
17
+ if (key?.trim() === name) {
18
+ return rest.join('=').trim();
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ /**
25
+ * Middleware to verify session tokens.
26
+ * Reads token from HttpOnly cookie (browser) or Authorization header (API clients).
27
+ * Looks up token in DB, attaches session to context.
28
+ * Also updates last_seen_at (sliding expiration) and IP on each request.
29
+ */
30
+ export function verifyToken(): Middleware {
31
+ const sessionService = inject(SessionService);
32
+
33
+ return async (context: HonoContext, next: () => Promise<void>) => {
34
+ const authHeader = context.req.header('Authorization');
35
+ const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
36
+ const token =
37
+ getCookieValue(context.req.header('Cookie'), getAuthConfig().session.cookieName) ??
38
+ bearerToken;
39
+
40
+ if (!token) {
41
+ context.set('session', null);
42
+ await next();
43
+ return;
44
+ }
45
+
46
+ const ip = context.req.header('x-forwarded-for') ?? context.req.header('x-real-ip');
47
+ const session = sessionService.validateSession(token, ip ?? undefined);
48
+
49
+ context.set('session', session);
50
+ await next();
51
+ };
52
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @brika/auth - Bootstrap Plugin
3
+ *
4
+ * Single entry point: opens SQLite, registers services,
5
+ * optionally wires HTTP middleware + routes.
6
+ *
7
+ * @example Hub
8
+ * ```ts
9
+ * await bootstrap()
10
+ * .use(auth({ dataDir, server: inject(ApiServer) }))
11
+ * .start();
12
+ * ```
13
+ *
14
+ * @example CLI
15
+ * ```ts
16
+ * await bootstrapCLI()
17
+ * .use(auth({ dataDir }))
18
+ * .start();
19
+ * ```
20
+ */
21
+
22
+ import type { Database } from 'bun:sqlite';
23
+ import { join } from 'node:path';
24
+ import { inject } from '@brika/di';
25
+ import type { Middleware, RouteDefinition } from '@brika/router';
26
+ import type { AuthConfig } from './config';
27
+ import { verifyToken } from './middleware/verifyToken';
28
+ import { allAuthRoutes as authRoutes } from './server/routes/index';
29
+ import { SessionService } from './services/SessionService';
30
+ import { openAuthDatabase, setupAuthServices } from './setup';
31
+
32
+ interface ApiServer {
33
+ addMiddleware(mw: Middleware): void;
34
+ addRoutes(routes: RouteDefinition[]): void;
35
+ }
36
+
37
+ export interface AuthPluginOptions {
38
+ /** Root data directory (e.g. ~/.brika or .brika) */
39
+ dataDir: string;
40
+ /** API server — pass to enable HTTP middleware + routes (hub mode) */
41
+ server?: ApiServer;
42
+ /** Auth configuration overrides (session TTL, password policy, etc.) */
43
+ config?: AuthConfig;
44
+ }
45
+
46
+ /**
47
+ * Auth bootstrap plugin.
48
+ */
49
+ export function auth(options: AuthPluginOptions) {
50
+ const { dataDir, server, config } = options;
51
+ let db: Database;
52
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
53
+
54
+ /** Run expired session cleanup every 6 hours */
55
+ const CLEANUP_INTERVAL = 6 * 60 * 60 * 1000;
56
+
57
+ return {
58
+ name: 'auth',
59
+
60
+ setup() {
61
+ db = openAuthDatabase(join(dataDir, 'auth.db'));
62
+ setupAuthServices(db, config);
63
+
64
+ if (server) {
65
+ server.addMiddleware(verifyToken());
66
+ server.addRoutes(authRoutes);
67
+ }
68
+ },
69
+
70
+ onStart() {
71
+ const sessionService = inject(SessionService);
72
+ // Initial cleanup on startup, then periodically
73
+ sessionService.cleanExpiredSessions();
74
+ cleanupTimer = setInterval(() => sessionService.cleanExpiredSessions(), CLEANUP_INTERVAL);
75
+ cleanupTimer.unref();
76
+ },
77
+
78
+ onStop() {
79
+ if (cleanupTimer) {
80
+ clearInterval(cleanupTimer);
81
+ cleanupTimer = null;
82
+ }
83
+ db?.close();
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Auth Provider — Session-based authentication context
3
+ *
4
+ * Usage:
5
+ * <AuthProvider>
6
+ * <App />
7
+ * </AuthProvider>
8
+ */
9
+
10
+ import React, { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
11
+ import { AuthClient, createAuthClient, Session } from '../client/AuthClient';
12
+
13
+ export interface AuthContextType {
14
+ user: Session['user'] | null;
15
+ session: Session | null;
16
+ isAuthenticated: boolean;
17
+ isLoading: boolean;
18
+ /** True when no admin exists and initial setup is required. */
19
+ needsSetup: boolean;
20
+ error: string | null;
21
+ client: AuthClient;
22
+ /** Clear the client-side session (e.g. after a 401 response). Does not call logout API. */
23
+ clearSession: () => void;
24
+ /** Update the session without affecting setup state. Used during onboarding. */
25
+ updateSession: (session: Session) => void;
26
+ /** Refresh session from the server (re-fetches /api/auth/session). */
27
+ refreshSession: () => Promise<void>;
28
+ }
29
+
30
+ export type AuthContextValue = AuthContextType;
31
+
32
+ export const AuthContext = createContext<AuthContextType | null>(null);
33
+
34
+ export interface AuthProviderProps {
35
+ children: ReactNode;
36
+ apiUrl?: string;
37
+ }
38
+
39
+ export function AuthProvider({ children, apiUrl }: Readonly<AuthProviderProps>) {
40
+ const client = useMemo(
41
+ () =>
42
+ createAuthClient({
43
+ apiUrl,
44
+ }),
45
+ []
46
+ );
47
+
48
+ const [session, setSession] = useState<Session | null>(null);
49
+ const [isLoading, setIsLoading] = useState(true);
50
+ const [needsSetup, setNeedsSetup] = useState(false);
51
+ const [error, setError] = useState<string | null>(null);
52
+
53
+ // Check for active session on mount (cookie is HttpOnly, invisible to JS)
54
+ useEffect(() => {
55
+ const loadSession = async () => {
56
+ try {
57
+ setIsLoading(true);
58
+ const [loaded, status] = await Promise.all([
59
+ client.getSession(),
60
+ client.checkSetupStatus(),
61
+ ]);
62
+ setSession(loaded);
63
+ setNeedsSetup(status.needsSetup);
64
+ } catch (err) {
65
+ const message = err instanceof Error ? err.message : 'Failed to load session';
66
+ setError(message);
67
+ } finally {
68
+ setIsLoading(false);
69
+ }
70
+ };
71
+
72
+ loadSession();
73
+ }, [client]);
74
+
75
+ const clearSession = useCallback(() => setSession(null), []);
76
+ const updateSession = useCallback((s: Session) => setSession(s), []);
77
+
78
+ const refreshSession = useCallback(async () => {
79
+ try {
80
+ const [loaded, status] = await Promise.all([client.getSession(), client.checkSetupStatus()]);
81
+ setSession(loaded);
82
+ setNeedsSetup(status.needsSetup);
83
+ } catch {
84
+ setSession(null);
85
+ }
86
+ }, [client]);
87
+
88
+ const value = useMemo<AuthContextType>(
89
+ () => ({
90
+ user: session?.user || null,
91
+ session,
92
+ isAuthenticated: session !== null,
93
+ isLoading,
94
+ needsSetup,
95
+ error,
96
+ client,
97
+ clearSession,
98
+ updateSession,
99
+ refreshSession,
100
+ }),
101
+ [session, isLoading, needsSetup, error, client, clearSession, updateSession, refreshSession]
102
+ );
103
+
104
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
105
+ }