Skip to content

Commit e28d8b2

Browse files
committed
refactor: isolate auth module using inversion of control
Create a standalone auth module at ~/auth that encapsulates all authentication logic, making it independent of the rest of the application through IoC interfaces. New auth module structure: - types.ts: Core types and IoC interfaces (IUserRepository, etc.) - session.server.ts: Cookie-based session management with HMAC-SHA256 - auth.server.ts: Main AuthService for user authentication - capabilities.server.ts: CapabilitiesService with helper utilities - oauth.server.ts: OAuthService and OAuth provider utilities - guards.server.ts: Auth guards factory and decorators - repositories.server.ts: Drizzle-based repository implementations - context.server.ts: Dependency injection/service composition root - index.server.ts: Server-side public API exports - index.ts: Client-side public API exports - client.ts: Client-side auth utilities The existing utils files now delegate to the new auth module for backward compatibility. Auth routes updated to use the new module directly.
1 parent fcce90d commit e28d8b2

20 files changed

+2249
-801
lines changed

src/auth/auth.server.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Auth Service
3+
*
4+
* Main authentication service that coordinates session validation
5+
* and user retrieval. Uses inversion of control for all dependencies.
6+
*/
7+
8+
import type {
9+
AuthUser,
10+
Capability,
11+
DbUser,
12+
IAuthService,
13+
ICapabilitiesRepository,
14+
ISessionService,
15+
IUserRepository,
16+
SessionCookieData,
17+
} from './types'
18+
import { AuthError } from './types'
19+
20+
// ============================================================================
21+
// Auth Service Implementation
22+
// ============================================================================
23+
24+
export class AuthService implements IAuthService {
25+
constructor(
26+
private sessionService: ISessionService,
27+
private userRepository: IUserRepository,
28+
private capabilitiesRepository: ICapabilitiesRepository,
29+
) {}
30+
31+
/**
32+
* Get current user from request
33+
* Returns null if not authenticated
34+
*/
35+
async getCurrentUser(request: Request): Promise<AuthUser | null> {
36+
const signedCookie = this.sessionService.getSessionCookie(request)
37+
38+
if (!signedCookie) {
39+
return null
40+
}
41+
42+
try {
43+
const cookieData = await this.sessionService.verifyCookie(signedCookie)
44+
45+
if (!cookieData) {
46+
console.error(
47+
'[AuthService] Session cookie verification failed - invalid signature or expired',
48+
)
49+
return null
50+
}
51+
52+
const result = await this.validateSession(cookieData)
53+
if (!result) {
54+
return null
55+
}
56+
57+
return this.mapDbUserToAuthUser(result.user, result.capabilities)
58+
} catch (error) {
59+
console.error('[AuthService] Failed to get user from session:', {
60+
error: error instanceof Error ? error.message : 'Unknown error',
61+
stack: error instanceof Error ? error.stack : undefined,
62+
})
63+
return null
64+
}
65+
}
66+
67+
/**
68+
* Validate session data against the database
69+
*/
70+
async validateSession(
71+
sessionData: SessionCookieData,
72+
): Promise<{ user: DbUser; capabilities: Capability[] } | null> {
73+
const user = await this.userRepository.findById(sessionData.userId)
74+
75+
if (!user) {
76+
console.error(
77+
`[AuthService] Session cookie references non-existent user ${sessionData.userId}`,
78+
)
79+
return null
80+
}
81+
82+
// Verify session version matches (for session revocation)
83+
if (user.sessionVersion !== sessionData.version) {
84+
console.error(
85+
`[AuthService] Session version mismatch for user ${user.id} - expected ${user.sessionVersion}, got ${sessionData.version}`,
86+
)
87+
return null
88+
}
89+
90+
// Get effective capabilities
91+
const capabilities =
92+
await this.capabilitiesRepository.getEffectiveCapabilities(user.id)
93+
94+
return { user, capabilities }
95+
}
96+
97+
/**
98+
* Map database user to AuthUser type
99+
*/
100+
private mapDbUserToAuthUser(user: DbUser, capabilities: Capability[]): AuthUser {
101+
return {
102+
userId: user.id,
103+
email: user.email,
104+
name: user.name,
105+
image: user.image,
106+
displayUsername: user.displayUsername,
107+
capabilities,
108+
adsDisabled: user.adsDisabled,
109+
interestedInHidingAds: user.interestedInHidingAds,
110+
}
111+
}
112+
}
113+
114+
// ============================================================================
115+
// Auth Guard Functions
116+
// ============================================================================
117+
118+
/**
119+
* Require authentication - throws if not authenticated
120+
*/
121+
export async function requireAuthentication(
122+
authService: IAuthService,
123+
request: Request,
124+
): Promise<AuthUser> {
125+
const user = await authService.getCurrentUser(request)
126+
if (!user) {
127+
throw new AuthError('Not authenticated', 'NOT_AUTHENTICATED')
128+
}
129+
return user
130+
}
131+
132+
/**
133+
* Require specific capability - throws if not authorized
134+
*/
135+
export async function requireCapability(
136+
authService: IAuthService,
137+
request: Request,
138+
capability: Capability,
139+
): Promise<AuthUser> {
140+
const user = await requireAuthentication(authService, request)
141+
142+
const hasAccess =
143+
user.capabilities.includes('admin') || user.capabilities.includes(capability)
144+
145+
if (!hasAccess) {
146+
throw new AuthError(
147+
`Missing required capability: ${capability}`,
148+
'MISSING_CAPABILITY',
149+
)
150+
}
151+
152+
return user
153+
}
154+
155+
/**
156+
* Require admin capability - throws if not admin
157+
*/
158+
export async function requireAdmin(
159+
authService: IAuthService,
160+
request: Request,
161+
): Promise<AuthUser> {
162+
return requireCapability(authService, request, 'admin')
163+
}

src/auth/capabilities.server.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Capabilities Service
3+
*
4+
* Handles authorization via capability-based access control.
5+
* Uses inversion of control for data access.
6+
*/
7+
8+
import type { Capability, ICapabilitiesRepository, AuthUser } from './types'
9+
10+
// ============================================================================
11+
// Capabilities Service
12+
// ============================================================================
13+
14+
export class CapabilitiesService {
15+
constructor(private repository: ICapabilitiesRepository) {}
16+
17+
/**
18+
* Get effective capabilities for a user (direct + role-based)
19+
*/
20+
async getEffectiveCapabilities(userId: string): Promise<Capability[]> {
21+
return this.repository.getEffectiveCapabilities(userId)
22+
}
23+
24+
/**
25+
* Get effective capabilities for multiple users efficiently
26+
*/
27+
async getBulkEffectiveCapabilities(
28+
userIds: string[],
29+
): Promise<Record<string, Capability[]>> {
30+
return this.repository.getBulkEffectiveCapabilities(userIds)
31+
}
32+
}
33+
34+
// ============================================================================
35+
// Capability Checking Utilities
36+
// ============================================================================
37+
38+
/**
39+
* Check if user has a specific capability
40+
* Admin users have access to all capabilities
41+
*/
42+
export function hasCapability(
43+
capabilities: Capability[],
44+
requiredCapability: Capability,
45+
): boolean {
46+
return (
47+
capabilities.includes('admin') || capabilities.includes(requiredCapability)
48+
)
49+
}
50+
51+
/**
52+
* Check if user has all specified capabilities
53+
*/
54+
export function hasAllCapabilities(
55+
capabilities: Capability[],
56+
requiredCapabilities: Capability[],
57+
): boolean {
58+
if (capabilities.includes('admin')) {
59+
return true
60+
}
61+
return requiredCapabilities.every((cap) => capabilities.includes(cap))
62+
}
63+
64+
/**
65+
* Check if user has any of the specified capabilities
66+
*/
67+
export function hasAnyCapability(
68+
capabilities: Capability[],
69+
requiredCapabilities: Capability[],
70+
): boolean {
71+
if (capabilities.includes('admin')) {
72+
return true
73+
}
74+
return requiredCapabilities.some((cap) => capabilities.includes(cap))
75+
}
76+
77+
/**
78+
* Check if user is admin
79+
*/
80+
export function isAdmin(capabilities: Capability[]): boolean {
81+
return capabilities.includes('admin')
82+
}
83+
84+
/**
85+
* Check if AuthUser has a specific capability
86+
*/
87+
export function userHasCapability(
88+
user: AuthUser | null | undefined,
89+
capability: Capability,
90+
): boolean {
91+
if (!user) return false
92+
return hasCapability(user.capabilities, capability)
93+
}
94+
95+
/**
96+
* Check if AuthUser is admin
97+
*/
98+
export function userIsAdmin(user: AuthUser | null | undefined): boolean {
99+
if (!user) return false
100+
return isAdmin(user.capabilities)
101+
}

src/auth/client.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Auth Client Module
3+
*
4+
* Client-side authentication utilities and navigation helpers.
5+
* This module is safe to import in browser code.
6+
*/
7+
8+
import type { OAuthProvider } from './types'
9+
10+
// ============================================================================
11+
// Auth Client
12+
// ============================================================================
13+
14+
/**
15+
* Client-side auth utilities for OAuth flows
16+
*/
17+
export const authClient = {
18+
signIn: {
19+
/**
20+
* Initiate OAuth sign-in with a social provider
21+
*/
22+
social: ({ provider }: { provider: OAuthProvider }) => {
23+
window.location.href = `/auth/${provider}/start`
24+
},
25+
},
26+
27+
/**
28+
* Sign out the current user
29+
*/
30+
signOut: async () => {
31+
window.location.href = '/auth/signout'
32+
},
33+
}
34+
35+
// ============================================================================
36+
// Navigation Helpers
37+
// ============================================================================
38+
39+
/**
40+
* Navigate to sign-in page
41+
*/
42+
export function navigateToSignIn(
43+
provider?: OAuthProvider,
44+
returnTo?: string,
45+
): void {
46+
if (provider) {
47+
const url = returnTo
48+
? `/auth/${provider}/start?returnTo=${encodeURIComponent(returnTo)}`
49+
: `/auth/${provider}/start`
50+
window.location.href = url
51+
} else {
52+
const url = returnTo
53+
? `/login?returnTo=${encodeURIComponent(returnTo)}`
54+
: '/login'
55+
window.location.href = url
56+
}
57+
}
58+
59+
/**
60+
* Navigate to sign-out
61+
*/
62+
export function navigateToSignOut(): void {
63+
window.location.href = '/auth/signout'
64+
}
65+
66+
/**
67+
* Get current URL path for return-to parameter
68+
*/
69+
export function getCurrentPath(): string {
70+
return window.location.pathname + window.location.search
71+
}

0 commit comments

Comments
 (0)