A social knowledge tool for researchers built on ATProto

Merge pull request #66 from weswalla/cyrus/sem-9-use-cookies-for-auth

feat: implement cookie-based authentication

authored by

Wesley Finck and committed by
GitHub
09c1b2e7 0933305f

+832 -888
+31
package-lock.json
··· 18 18 "@atproto/syntax": "^0.4.0", 19 19 "@atproto/xrpc-server": "^0.7.15", 20 20 "bullmq": "^5.56.8", 21 + "cookie-parser": "^1.4.7", 21 22 "cors": "^2.8.5", 22 23 "dotenv": "^16.5.0", 23 24 "express": "^5.1.0", ··· 31 32 "@flydotio/dockerfile": "^0.7.10", 32 33 "@testcontainers/postgresql": "^11.0.3", 33 34 "@testcontainers/redis": "^11.4.0", 35 + "@types/cookie-parser": "^1.4.9", 34 36 "@types/cors": "^2.8.18", 35 37 "@types/express": "^5.0.1", 36 38 "@types/ioredis": "^5.0.0", ··· 4394 4396 "@types/node": "*" 4395 4397 } 4396 4398 }, 4399 + "node_modules/@types/cookie-parser": { 4400 + "version": "1.4.9", 4401 + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", 4402 + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", 4403 + "dev": true, 4404 + "license": "MIT", 4405 + "peerDependencies": { 4406 + "@types/express": "*" 4407 + } 4408 + }, 4397 4409 "node_modules/@types/cookiejar": { 4398 4410 "version": "2.1.5", 4399 4411 "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", ··· 6297 6309 "engines": { 6298 6310 "node": ">= 0.6" 6299 6311 } 6312 + }, 6313 + "node_modules/cookie-parser": { 6314 + "version": "1.4.7", 6315 + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", 6316 + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", 6317 + "license": "MIT", 6318 + "dependencies": { 6319 + "cookie": "0.7.2", 6320 + "cookie-signature": "1.0.6" 6321 + }, 6322 + "engines": { 6323 + "node": ">= 0.8.0" 6324 + } 6325 + }, 6326 + "node_modules/cookie-parser/node_modules/cookie-signature": { 6327 + "version": "1.0.6", 6328 + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 6329 + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 6330 + "license": "MIT" 6300 6331 }, 6301 6332 "node_modules/cookie-signature": { 6302 6333 "version": "1.2.2",
+2
package.json
··· 59 59 "@atproto/syntax": "^0.4.0", 60 60 "@atproto/xrpc-server": "^0.7.15", 61 61 "bullmq": "^5.56.8", 62 + "cookie-parser": "^1.4.7", 62 63 "cors": "^2.8.5", 63 64 "dotenv": "^16.5.0", 64 65 "express": "^5.1.0", ··· 72 73 "@flydotio/dockerfile": "^0.7.10", 73 74 "@testcontainers/postgresql": "^11.0.3", 74 75 "@testcontainers/redis": "^11.4.0", 76 + "@types/cookie-parser": "^1.4.9", 75 77 "@types/cors": "^2.8.18", 76 78 "@types/express": "^5.0.1", 77 79 "@types/ioredis": "^5.0.0",
+1 -9
src/modules/atproto/infrastructure/services/FakeAtProtoOAuthProcessor.ts
··· 12 12 async generateAuthUrl(handle?: string): Promise<Result<string>> { 13 13 try { 14 14 // Generate tokens for the mock DID 15 - const mockDid = process.env.BSKY_DID || 'did:plc:mock123'; 16 - const tokenResult = await this.tokenService.generateToken(mockDid); 17 - 18 - if (tokenResult.isErr()) { 19 - return err(tokenResult.error); 20 - } 21 - 22 - const tokens = tokenResult.unwrap(); 23 - const mockUrl = `http://localhost:4000/auth/complete?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`; 15 + const mockUrl = `http://127.0.0.1:3000/api/users/oauth/callback?code=mockCode&state=mockState&iss=mockIssuer`; 24 16 return ok(mockUrl); 25 17 } catch (error: any) { 26 18 return err(error);
+14 -6
src/modules/user/infrastructure/http/controllers/CompleteOAuthSignInController.ts
··· 1 1 import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 2 import { Request, Response } from 'express'; 3 3 import { CompleteOAuthSignInUseCase } from '../../../application/use-cases/CompleteOAuthSignInUseCase'; 4 + import { CookieService } from '../../../../../shared/infrastructure/http/services/CookieService'; 4 5 import { configService } from 'src/shared/infrastructure/config'; 5 6 6 7 export class CompleteOAuthSignInController extends Controller { 7 - constructor(private completeOAuthSignInUseCase: CompleteOAuthSignInUseCase) { 8 + constructor( 9 + private completeOAuthSignInUseCase: CompleteOAuthSignInUseCase, 10 + private cookieService: CookieService, 11 + ) { 8 12 super(); 9 13 } 10 14 ··· 26 30 if (result.isErr()) { 27 31 // Instead of returning JSON, redirect with error 28 32 return res.redirect( 29 - `${process.env.FRONTEND_URL}/login?error=${encodeURIComponent(result.error.message)}`, 33 + `${appUrl}/login?error=${encodeURIComponent(result.error.message)}`, 30 34 ); 31 35 } 32 36 33 - // Redirect back to frontend with tokens in URL parameters 34 - return res.redirect( 35 - `${appUrl}/auth/complete?accessToken=${encodeURIComponent(result.value.accessToken)}&refreshToken=${encodeURIComponent(result.value.refreshToken)}`, 36 - ); 37 + // Set tokens in httpOnly cookies 38 + this.cookieService.setTokens(res, { 39 + accessToken: result.value.accessToken, 40 + refreshToken: result.value.refreshToken, 41 + }); 42 + 43 + // Redirect back to frontend without tokens in URL (more secure) 44 + return res.redirect(`${appUrl}/auth/complete`); 37 45 } catch (error: any) { 38 46 return res.redirect( 39 47 `${appUrl}/login?error=${encodeURIComponent(error.message || 'Unknown error')}`,
+9 -1
src/modules/user/infrastructure/http/controllers/LoginWithAppPasswordController.ts
··· 1 1 import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 2 import { Request, Response } from 'express'; 3 3 import { LoginWithAppPasswordUseCase } from '../../../application/use-cases/LoginWithAppPasswordUseCase'; 4 + import { CookieService } from '../../../../../shared/infrastructure/http/services/CookieService'; 4 5 5 6 export class LoginWithAppPasswordController extends Controller { 6 7 constructor( 7 8 private loginWithAppPasswordUseCase: LoginWithAppPasswordUseCase, 9 + private cookieService: CookieService, 8 10 ) { 9 11 super(); 10 12 } ··· 26 28 return this.badRequest(res, result.error.message); 27 29 } 28 30 29 - return this.ok(res, { 31 + // Set tokens in httpOnly cookies 32 + this.cookieService.setTokens(res, { 30 33 accessToken: result.value.accessToken, 31 34 refreshToken: result.value.refreshToken, 35 + }); 36 + 37 + return this.ok(res, { 38 + success: true, 39 + message: 'Logged in successfully', 32 40 }); 33 41 } catch (error: any) { 34 42 return this.fail(res, error.message || 'Unknown error');
+14 -2
src/modules/user/infrastructure/http/controllers/LogoutController.ts
··· 1 1 import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 2 import { Request, Response } from 'express'; 3 3 import { LogoutUseCase } from '../../../application/use-cases/LogoutUseCase'; 4 + import { CookieService } from '../../../../../shared/infrastructure/http/services/CookieService'; 4 5 5 6 export class LogoutController extends Controller { 6 - constructor(private logoutUseCase: LogoutUseCase) { 7 + constructor( 8 + private logoutUseCase: LogoutUseCase, 9 + private cookieService: CookieService, 10 + ) { 7 11 super(); 8 12 } 9 13 10 14 async executeImpl(req: Request, res: Response): Promise<any> { 11 15 try { 12 - const refreshToken = req.body?.refreshToken; 16 + // Try to get refresh token from cookie first, then fall back to request body 17 + const refreshToken = 18 + this.cookieService.getRefreshToken(req) || req.body?.refreshToken; 13 19 14 20 const result = await this.logoutUseCase.execute({ 15 21 refreshToken, 16 22 }); 17 23 24 + // Clear authentication cookies regardless of use case result 25 + this.cookieService.clearTokens(res); 26 + 18 27 if (result.isErr()) { 19 28 return this.fail(res, result.error); 20 29 } 21 30 22 31 return this.ok(res, result.value); 23 32 } catch (error: any) { 33 + // Always clear cookies on logout, even if there's an error 34 + this.cookieService.clearTokens(res); 35 + 24 36 return this.ok(res, { 25 37 success: true, 26 38 message: 'Logged out (client-side cleanup completed)',
+15 -2
src/modules/user/infrastructure/http/controllers/RefreshAccessTokenController.ts
··· 1 1 import { Controller } from '../../../../../shared/infrastructure/http/Controller'; 2 2 import { Request, Response } from 'express'; 3 3 import { RefreshAccessTokenUseCase } from '../../../application/use-cases/RefreshAccessTokenUseCase'; 4 + import { CookieService } from '../../../../../shared/infrastructure/http/services/CookieService'; 4 5 5 6 export class RefreshAccessTokenController extends Controller { 6 - constructor(private refreshAccessTokenUseCase: RefreshAccessTokenUseCase) { 7 + constructor( 8 + private refreshAccessTokenUseCase: RefreshAccessTokenUseCase, 9 + private cookieService: CookieService, 10 + ) { 7 11 super(); 8 12 } 9 13 10 14 async executeImpl(req: Request, res: Response): Promise<any> { 11 15 try { 12 - const { refreshToken } = req.body; 16 + // Try to get refresh token from cookie first, then fall back to request body 17 + let refreshToken = 18 + this.cookieService.getRefreshToken(req) || req.body?.refreshToken; 13 19 14 20 if (!refreshToken) { 15 21 return this.badRequest(res, 'Refresh token is required'); ··· 23 29 return this.fail(res, result.error); 24 30 } 25 31 32 + // Set new tokens in cookies 33 + this.cookieService.setTokens(res, { 34 + accessToken: result.value.accessToken, 35 + refreshToken: result.value.refreshToken, 36 + }); 37 + 38 + // Also return tokens in response body for backward compatibility 26 39 return this.ok(res, result.value); 27 40 } catch (error: any) { 28 41 return this.fail(res, error);
+6 -2
src/modules/user/infrastructure/services/FakeJwtTokenService.ts
··· 4 4 import { ITokenService } from '../../application/services/ITokenService'; 5 5 import { ITokenRepository } from '../../domain/repositories/ITokenRepository'; 6 6 import { TokenPair } from '../../application/dtos/TokenDTO'; 7 + import { EnvironmentConfigService } from 'src/shared/infrastructure/config/EnvironmentConfigService'; 7 8 8 9 export class FakeJwtTokenService implements ITokenService { 9 10 private jwtSecret: string; 10 - private accessTokenExpiresIn: number = 3600; // 1 hour 11 - private refreshTokenExpiresIn: number = 2592000; // 30 days 11 + private accessTokenExpiresIn: number = 12 + new EnvironmentConfigService().getAuthConfig().accessTokenExpiresIn || 3600; // 1 hour 13 + private refreshTokenExpiresIn: number = 14 + new EnvironmentConfigService().getAuthConfig().refreshTokenExpiresIn || 15 + 2592000; // 30 days 12 16 13 17 constructor(private tokenRepository: ITokenRepository) { 14 18 this.jwtSecret = process.env.MOCK_ACCESS_TOKEN || 'mock-access-token-123';
-125
src/modules/user/tests/integration/auth-integration.integration.test.ts
··· 1 - import express from 'express'; 2 - import request from 'supertest'; 3 - import { JwtTokenService } from '../../infrastructure/services/JwtTokenService'; 4 - import { 5 - AuthMiddleware, 6 - AuthenticatedRequest, 7 - } from '../../../../shared/infrastructure/http/middleware/AuthMiddleware'; 8 - import { InMemoryTokenRepository } from 'src/modules/user/tests/infrastructure/InMemoryTokenRepository'; 9 - 10 - describe('Auth Integration Tests', () => { 11 - let app: express.Application; 12 - let tokenService: JwtTokenService; 13 - let validToken: string; 14 - let refreshToken: string; 15 - 16 - beforeAll(async () => { 17 - // Setup 18 - const mockTokenRepo = new InMemoryTokenRepository(); 19 - tokenService = new JwtTokenService( 20 - mockTokenRepo, 21 - 'test-secret', 22 - 60, // Short expiry for testing 23 - 300, // Short refresh expiry 24 - ); 25 - 26 - // Generate a valid token for testing 27 - const tokenResult = await tokenService.generateToken('did:plc:testuser'); 28 - if (tokenResult.isOk()) { 29 - validToken = tokenResult.value.accessToken; 30 - refreshToken = tokenResult.value.refreshToken; 31 - } 32 - 33 - // Create Express app 34 - app = express(); 35 - app.use(express.json()); 36 - 37 - const authMiddleware = new AuthMiddleware(tokenService); 38 - 39 - // Public route 40 - app.get('/api/public', (req, res) => { 41 - res.json({ message: 'Public route' }); 42 - }); 43 - 44 - // Protected route 45 - app.get( 46 - '/api/protected', 47 - authMiddleware.ensureAuthenticated(), 48 - (req: AuthenticatedRequest, res) => { 49 - res.json({ message: 'Protected route', did: req.did }); 50 - }, 51 - ); 52 - 53 - // Optional auth route 54 - app.get( 55 - '/api/optional', 56 - authMiddleware.optionalAuth(), 57 - (req: AuthenticatedRequest, res) => { 58 - res.json({ 59 - message: 'Optional auth route', 60 - did: req.did || 'anonymous', 61 - }); 62 - }, 63 - ); 64 - 65 - // Refresh token route 66 - app.post('/api/refresh', async (req, res) => { 67 - const result = await tokenService.refreshToken(req.body.refreshToken); 68 - if (result.isOk() && result.value) { 69 - res.json(result.value); 70 - } else { 71 - res.status(401).json({ message: 'Invalid refresh token' }); 72 - } 73 - }); 74 - }); 75 - 76 - test('Public route should be accessible without token', async () => { 77 - const response = await request(app).get('/api/public'); 78 - expect(response.status).toBe(200); 79 - expect(response.body.message).toBe('Public route'); 80 - }); 81 - 82 - test('Protected route should require valid token', async () => { 83 - // Without token 84 - const noTokenResponse = await request(app).get('/api/protected'); 85 - expect(noTokenResponse.status).toBe(401); 86 - 87 - // With invalid token 88 - const invalidTokenResponse = await request(app) 89 - .get('/api/protected') 90 - .set('Authorization', 'Bearer invalid_token'); 91 - expect(invalidTokenResponse.status).toBe(403); 92 - 93 - // With valid token 94 - const validTokenResponse = await request(app) 95 - .get('/api/protected') 96 - .set('Authorization', `Bearer ${validToken}`); 97 - expect(validTokenResponse.status).toBe(200); 98 - expect(validTokenResponse.body.did).toBe('did:plc:testuser'); 99 - }); 100 - 101 - test('Optional auth route should work with or without token', async () => { 102 - // Without token 103 - const noTokenResponse = await request(app).get('/api/optional'); 104 - expect(noTokenResponse.status).toBe(200); 105 - expect(noTokenResponse.body.did).toBe('anonymous'); 106 - 107 - // With valid token 108 - const validTokenResponse = await request(app) 109 - .get('/api/optional') 110 - .set('Authorization', `Bearer ${validToken}`); 111 - expect(validTokenResponse.status).toBe(200); 112 - expect(validTokenResponse.body.did).toBe('did:plc:testuser'); 113 - }); 114 - 115 - test('Should be able to refresh token', async () => { 116 - const response = await request(app) 117 - .post('/api/refresh') 118 - .send({ refreshToken }); 119 - 120 - expect(response.status).toBe(200); 121 - expect(response.body.accessToken).toBeDefined(); 122 - expect(response.body.refreshToken).toBeDefined(); 123 - expect(response.body.expiresIn).toBeDefined(); 124 - }); 125 - });
+1 -1
src/shared/infrastructure/config/EnvironmentConfigService.ts
··· 88 88 host: process.env.HOST || '127.0.0.1', 89 89 }, 90 90 app: { 91 - appUrl: process.env.APP_URL || 'http://localhost:4000', 91 + appUrl: process.env.APP_URL || 'http://127.0.0.1:4000', 92 92 }, 93 93 iframely: { 94 94 apiKey: process.env.IFRAMELY_API_KEY || '',
+31 -3
src/shared/infrastructure/http/app.ts
··· 1 1 import express, { Express } from 'express'; 2 2 import cors from 'cors'; 3 + import cookieParser from 'cookie-parser'; 3 4 import { Router } from 'express'; 4 5 import { createUserRoutes } from '../../../modules/user/infrastructure/http/routes/userRoutes'; 5 6 import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes'; ··· 16 17 ): Express => { 17 18 const app = express(); 18 19 20 + // Determine allowed origins based on environment 21 + const getAllowedOrigins = () => { 22 + const environment = configService.get().environment; 23 + const appUrl = configService.getAppConfig().appUrl; 24 + 25 + switch (environment) { 26 + case 'prod': 27 + return ['https://semble.so', 'https://api.semble.so']; 28 + case 'dev': 29 + return ['https://dev.semble.so', 'https://api.dev.semble.so']; 30 + case 'local': 31 + default: 32 + // Allow both localhost:4000 and configured appUrl for flexibility 33 + return [ 34 + 'http://localhost:4000', 35 + 'http://127.0.0.1:4000', 36 + appUrl, 37 + 'http://localhost:3000', 38 + 'http://127.0.0.1:3000', 39 + ]; 40 + } 41 + }; 42 + 19 43 app.use( 20 44 cors({ 21 - origin: '*', 45 + origin: getAllowedOrigins(), 22 46 methods: ['GET', 'POST', 'PUT', 'DELETE'], 23 - credentials: false, 47 + credentials: true, // Required for cookies to work in cross-origin requests 24 48 }), 25 49 ); 26 50 27 51 // Middleware setup 52 + app.use(cookieParser()); // Parse cookies from incoming requests 28 53 app.use(express.json()); 29 54 app.use(express.urlencoded({ extended: true })); 30 55 ··· 32 57 const repositories = RepositoryFactory.create(configService); 33 58 const services = ServiceFactory.createForWebApp(configService, repositories); 34 59 const useCases = UseCaseFactory.createForWebApp(repositories, services); 35 - const controllers = ControllerFactory.create(useCases); 60 + const controllers = ControllerFactory.create( 61 + useCases, 62 + services.cookieService, 63 + ); 36 64 37 65 // Routes 38 66 const userRouter = Router();
+9 -2
src/shared/infrastructure/http/factories/ControllerFactory.ts
··· 31 31 import { GetLibrariesForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetLibrariesForUrlController'; 32 32 import { GetCollectionsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionsForUrlController'; 33 33 import { GetNoteCardsForUrlController } from '../../../../modules/cards/infrastructure/http/controllers/GetNoteCardsForUrlController'; 34 + import { CookieService } from '../services/CookieService'; 34 35 35 36 export interface Controllers { 36 37 // User controllers ··· 71 72 } 72 73 73 74 export class ControllerFactory { 74 - static create(useCases: UseCases): Controllers { 75 + static create(useCases: UseCases, cookieService: CookieService): Controllers { 75 76 return { 76 77 // User controllers 77 78 loginWithAppPasswordController: new LoginWithAppPasswordController( 78 79 useCases.loginWithAppPasswordUseCase, 80 + cookieService, 79 81 ), 80 - logoutController: new LogoutController(useCases.logoutUseCase), 82 + logoutController: new LogoutController( 83 + useCases.logoutUseCase, 84 + cookieService, 85 + ), 81 86 initiateOAuthSignInController: new InitiateOAuthSignInController( 82 87 useCases.initiateOAuthSignInUseCase, 83 88 ), 84 89 completeOAuthSignInController: new CompleteOAuthSignInController( 85 90 useCases.completeOAuthSignInUseCase, 91 + cookieService, 86 92 ), 87 93 getMyProfileController: new GetMyProfileController( 88 94 useCases.getMyProfileUseCase, ··· 92 98 ), 93 99 refreshAccessTokenController: new RefreshAccessTokenController( 94 100 useCases.refreshAccessTokenUseCase, 101 + cookieService, 95 102 ), 96 103 generateExtensionTokensController: new GenerateExtensionTokensController( 97 104 useCases.generateExtensionTokensUseCase,
+11 -1
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 50 50 import { CardCollectionSaga } from '../../../../modules/feeds/application/sagas/CardCollectionSaga'; 51 51 import { ATProtoIdentityResolutionService } from '../../../../modules/atproto/infrastructure/services/ATProtoIdentityResolutionService'; 52 52 import { IIdentityResolutionService } from '../../../../modules/atproto/domain/services/IIdentityResolutionService'; 53 + import { CookieService } from '../services/CookieService'; 53 54 54 55 // Shared services needed by both web app and workers 55 56 export interface SharedServices { ··· 62 63 nodeOauthClient: NodeOAuthClient; 63 64 identityResolutionService: IIdentityResolutionService; 64 65 configService: EnvironmentConfigService; 66 + cookieService: CookieService; 65 67 } 66 68 67 69 // Web app specific services (includes publishers, auth middleware) ··· 74 76 cardCollectionService: CardCollectionService; 75 77 authMiddleware: AuthMiddleware; 76 78 eventPublisher: IEventPublisher; 79 + cookieService: CookieService; 77 80 } 78 81 79 82 // Worker specific services (includes subscribers) ··· 152 155 cardCollectionService, 153 156 ); 154 157 155 - const authMiddleware = new AuthMiddleware(sharedServices.tokenService); 158 + const authMiddleware = new AuthMiddleware( 159 + sharedServices.tokenService, 160 + sharedServices.cookieService, 161 + ); 156 162 157 163 const useInMemoryEvents = process.env.USE_IN_MEMORY_EVENTS === 'true'; 158 164 ··· 287 293 atProtoAgentService, 288 294 ); 289 295 296 + // Cookie Service 297 + const cookieService = new CookieService(configService); 298 + 290 299 return { 291 300 tokenService, 292 301 userAuthService, ··· 297 306 nodeOauthClient, 298 307 identityResolutionService, 299 308 configService, 309 + cookieService, 300 310 }; 301 311 } 302 312 }
+112 -13
src/shared/infrastructure/http/middleware/AuthMiddleware.ts
··· 1 1 import { Request, Response, NextFunction } from 'express'; 2 2 import { ITokenService } from '../../../../modules/user/application/services/ITokenService'; 3 + import { CookieService } from '../services/CookieService'; 3 4 4 5 export interface AuthenticatedRequest extends Request { 5 6 did?: string; 6 7 } 7 8 8 9 export class AuthMiddleware { 9 - constructor(private tokenService: ITokenService) {} 10 + constructor( 11 + private tokenService: ITokenService, 12 + private cookieService: CookieService, 13 + ) {} 10 14 15 + /** 16 + * Extract access token from request - checks both cookies and Authorization header 17 + * Priority: Cookie > Bearer token (for backward compatibility) 18 + */ 19 + private extractAccessToken(req: AuthenticatedRequest): string | undefined { 20 + // First, try to get token from cookie 21 + const cookieToken = this.cookieService.getAccessToken(req); 22 + if (cookieToken) { 23 + return cookieToken; 24 + } 25 + 26 + // Fallback to Authorization header for backward compatibility 27 + const authHeader = req.headers.authorization; 28 + if (authHeader && authHeader.startsWith('Bearer ')) { 29 + return authHeader.substring(7); // Remove 'Bearer ' prefix 30 + } 31 + 32 + return undefined; 33 + } 34 + 35 + /** 36 + * Require authentication - accepts both cookie-based and Bearer token auth 37 + * This is the unified method that supports both authentication methods 38 + */ 11 39 public ensureAuthenticated() { 12 40 return async ( 13 41 req: AuthenticatedRequest, ··· 15 43 next: NextFunction, 16 44 ): Promise<void> => { 17 45 try { 18 - // Extract token from Authorization header 19 - const authHeader = req.headers.authorization; 20 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 46 + const token = this.extractAccessToken(req); 47 + 48 + if (!token) { 21 49 res.status(401).json({ message: 'No access token provided' }); 22 - return; // Stop execution after sending a response 50 + return; 23 51 } 24 - 25 - const token = authHeader.substring(7); // Remove 'Bearer ' prefix 26 52 27 53 // Validate token 28 54 const didResult = await this.tokenService.validateToken(token); 29 55 30 56 if (didResult.isErr() || !didResult.value) { 31 57 res.status(403).json({ message: 'Invalid or expired token' }); 32 - return; // Stop execution after sending a response 58 + return; 33 59 } 34 60 35 61 // Attach user DID to request for use in controllers ··· 43 69 }; 44 70 } 45 71 72 + /** 73 + * Optional authentication - accepts both cookie-based and Bearer token auth 74 + * Continues even if no token is provided 75 + */ 46 76 public optionalAuth() { 47 77 return async ( 48 78 req: AuthenticatedRequest, ··· 50 80 next: NextFunction, 51 81 ) => { 52 82 try { 53 - // Extract token from Authorization header 54 - const authHeader = req.headers.authorization; 55 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 83 + const token = this.extractAccessToken(req); 84 + 85 + if (!token) { 56 86 // No token, but that's okay - continue without authentication 57 87 return next(); 58 88 } 59 - 60 - const token = authHeader.substring(7); // Remove 'Bearer ' prefix 61 89 62 90 // Validate token 63 91 const didResult = await this.tokenService.validateToken(token); ··· 72 100 } catch (error) { 73 101 // Continue without authentication in case of error 74 102 next(); 103 + } 104 + }; 105 + } 106 + 107 + /** 108 + * Require Bearer token authentication only (legacy support) 109 + * Use this when you specifically need Bearer token auth 110 + */ 111 + public requireBearerAuth() { 112 + return async ( 113 + req: AuthenticatedRequest, 114 + res: Response, 115 + next: NextFunction, 116 + ): Promise<void> => { 117 + try { 118 + const authHeader = req.headers.authorization; 119 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 120 + res.status(401).json({ message: 'No Bearer token provided' }); 121 + return; 122 + } 123 + 124 + const token = authHeader.substring(7); 125 + 126 + // Validate token 127 + const didResult = await this.tokenService.validateToken(token); 128 + 129 + if (didResult.isErr() || !didResult.value) { 130 + res.status(403).json({ message: 'Invalid or expired token' }); 131 + return; 132 + } 133 + 134 + req.did = didResult.value; 135 + next(); 136 + } catch (error) { 137 + res.status(500).json({ message: 'Authentication error' }); 138 + } 139 + }; 140 + } 141 + 142 + /** 143 + * Require cookie-based authentication only 144 + * Use this when you specifically need cookie auth (e.g., CSRF protection) 145 + */ 146 + public requireCookieAuth() { 147 + return async ( 148 + req: AuthenticatedRequest, 149 + res: Response, 150 + next: NextFunction, 151 + ): Promise<void> => { 152 + try { 153 + const token = this.cookieService.getAccessToken(req); 154 + 155 + if (!token) { 156 + res 157 + .status(401) 158 + .json({ message: 'No authentication cookie provided' }); 159 + return; 160 + } 161 + 162 + // Validate token 163 + const didResult = await this.tokenService.validateToken(token); 164 + 165 + if (didResult.isErr() || !didResult.value) { 166 + res.status(403).json({ message: 'Invalid or expired token' }); 167 + return; 168 + } 169 + 170 + req.did = didResult.value; 171 + next(); 172 + } catch (error) { 173 + res.status(500).json({ message: 'Authentication error' }); 75 174 } 76 175 }; 77 176 }
+146
src/shared/infrastructure/http/services/CookieService.ts
··· 1 + import { Response, Request } from 'express'; 2 + import { EnvironmentConfigService } from '../../config/EnvironmentConfigService'; 3 + 4 + export interface CookieOptions { 5 + httpOnly: boolean; 6 + secure: boolean; 7 + sameSite: 'strict' | 'lax' | 'none'; 8 + maxAge: number; 9 + path: string; 10 + domain?: string; 11 + } 12 + 13 + export class CookieService { 14 + private readonly ACCESS_TOKEN_COOKIE = 'accessToken'; 15 + private readonly REFRESH_TOKEN_COOKIE = 'refreshToken'; 16 + 17 + constructor(private configService: EnvironmentConfigService) {} 18 + 19 + /** 20 + * Get cookie domain based on environment 21 + * - local: undefined (defaults to localhost) 22 + * - dev: .dev.semble.so (allows cookies across api.dev.semble.so and dev.semble.so) 23 + * - prod: .semble.so (allows cookies across api.semble.so and semble.so) 24 + */ 25 + private getCookieDomain(): string | undefined { 26 + const environment = this.configService.get().environment; 27 + 28 + switch (environment) { 29 + case 'prod': 30 + return '.semble.so'; 31 + case 'dev': 32 + return '.dev.semble.so'; 33 + case 'local': 34 + default: 35 + return undefined; // Don't set domain for localhost 36 + } 37 + } 38 + 39 + /** 40 + * Get base cookie options based on environment 41 + */ 42 + private getBaseCookieOptions(): Omit<CookieOptions, 'maxAge'> { 43 + const environment = this.configService.get().environment; 44 + const isProduction = environment === 'prod'; 45 + 46 + return { 47 + httpOnly: true, 48 + secure: isProduction, // Only use secure cookies in production (requires HTTPS) 49 + sameSite: 'lax', // 'lax' allows cookies on same-site navigation, better than 'strict' for auth flows 50 + path: '/', 51 + domain: this.getCookieDomain(), 52 + }; 53 + } 54 + 55 + /** 56 + * Set access token cookie 57 + */ 58 + public setAccessToken(res: Response, accessToken: string): void { 59 + const accessTokenExpiresIn = 60 + this.configService.getAuthConfig().accessTokenExpiresIn; 61 + 62 + const options: CookieOptions = { 63 + ...this.getBaseCookieOptions(), 64 + maxAge: accessTokenExpiresIn * 1000, // Convert seconds to milliseconds 65 + }; 66 + 67 + res.cookie(this.ACCESS_TOKEN_COOKIE, accessToken, options); 68 + } 69 + 70 + /** 71 + * Set refresh token cookie 72 + */ 73 + public setRefreshToken(res: Response, refreshToken: string): void { 74 + const refreshTokenExpiresIn = 75 + this.configService.getAuthConfig().refreshTokenExpiresIn; 76 + 77 + const options: CookieOptions = { 78 + ...this.getBaseCookieOptions(), 79 + maxAge: refreshTokenExpiresIn * 1000, // Convert seconds to milliseconds 80 + }; 81 + 82 + res.cookie(this.REFRESH_TOKEN_COOKIE, refreshToken, options); 83 + } 84 + 85 + /** 86 + * Set both access and refresh tokens 87 + */ 88 + public setTokens( 89 + res: Response, 90 + tokens: { accessToken: string; refreshToken: string }, 91 + ): void { 92 + this.setAccessToken(res, tokens.accessToken); 93 + this.setRefreshToken(res, tokens.refreshToken); 94 + } 95 + 96 + /** 97 + * Get access token from cookies 98 + */ 99 + public getAccessToken(req: Request): string | undefined { 100 + return req.cookies?.[this.ACCESS_TOKEN_COOKIE]; 101 + } 102 + 103 + /** 104 + * Get refresh token from cookies 105 + */ 106 + public getRefreshToken(req: Request): string | undefined { 107 + return req.cookies?.[this.REFRESH_TOKEN_COOKIE]; 108 + } 109 + 110 + /** 111 + * Clear both access and refresh token cookies 112 + */ 113 + public clearTokens(res: Response): void { 114 + const cookieOptions = { 115 + ...this.getBaseCookieOptions(), 116 + maxAge: 0, // Expire immediately 117 + }; 118 + 119 + res.clearCookie(this.ACCESS_TOKEN_COOKIE, cookieOptions); 120 + res.clearCookie(this.REFRESH_TOKEN_COOKIE, cookieOptions); 121 + } 122 + 123 + /** 124 + * Clear access token cookie only 125 + */ 126 + public clearAccessToken(res: Response): void { 127 + const cookieOptions = { 128 + ...this.getBaseCookieOptions(), 129 + maxAge: 0, 130 + }; 131 + 132 + res.clearCookie(this.ACCESS_TOKEN_COOKIE, cookieOptions); 133 + } 134 + 135 + /** 136 + * Clear refresh token cookie only 137 + */ 138 + public clearRefreshToken(res: Response): void { 139 + const cookieOptions = { 140 + ...this.getBaseCookieOptions(), 141 + maxAge: 0, 142 + }; 143 + 144 + res.clearCookie(this.REFRESH_TOKEN_COOKIE, cookieOptions); 145 + } 146 + }
+14 -67
src/webapp/api-client/ApiClient.ts
··· 5 5 UserClient, 6 6 FeedClient, 7 7 } from './clients'; 8 - import { TokenManager } from '../services/TokenManager'; 9 - import { 10 - createClientTokenManager, 11 - createServerTokenManager, 12 - } from '../services/auth'; 13 8 import type { 14 9 // Request types 15 10 AddUrlToLibraryRequest, ··· 77 72 private userClient: UserClient; 78 73 private feedClient: FeedClient; 79 74 80 - constructor( 81 - private baseUrl: string, 82 - private tokenManager?: TokenManager, 83 - ) { 84 - this.queryClient = new QueryClient(baseUrl, tokenManager); 85 - this.cardClient = new CardClient(baseUrl, tokenManager); 86 - this.collectionClient = new CollectionClient(baseUrl, tokenManager); 87 - this.userClient = new UserClient(baseUrl, tokenManager); 88 - this.feedClient = new FeedClient(baseUrl, tokenManager); 89 - } 90 - 91 - // Helper to check if client is authenticated 92 - get isAuthenticated(): boolean { 93 - return !!this.tokenManager; 94 - } 95 - 96 - // Helper method to ensure authentication for protected operations 97 - private requireAuthentication(operation: string): void { 98 - if (!this.tokenManager) { 99 - throw new Error(`Authentication required for ${operation}`); 100 - } 75 + constructor(private baseUrl: string) { 76 + this.queryClient = new QueryClient(baseUrl); 77 + this.cardClient = new CardClient(baseUrl); 78 + this.collectionClient = new CollectionClient(baseUrl); 79 + this.userClient = new UserClient(baseUrl); 80 + this.feedClient = new FeedClient(baseUrl); 101 81 } 102 82 103 83 // Query operations - delegate to QueryClient ··· 108 88 async getMyUrlCards( 109 89 params?: GetMyUrlCardsParams, 110 90 ): Promise<GetUrlCardsResponse> { 111 - this.requireAuthentication('getMyUrlCards'); 112 91 return this.queryClient.getMyUrlCards(params); 113 92 } 114 93 ··· 123 102 async getLibrariesForCard( 124 103 cardId: string, 125 104 ): Promise<GetLibrariesForCardResponse> { 126 - this.requireAuthentication('getLibrariesForCard'); 127 105 return this.queryClient.getLibrariesForCard(cardId); 128 106 } 129 107 130 108 async getMyProfile(): Promise<GetProfileResponse> { 131 - this.requireAuthentication('getMyProfile'); 132 109 return this.queryClient.getMyProfile(); 133 110 } 134 111 ··· 152 129 async getMyCollections( 153 130 params?: GetMyCollectionsParams, 154 131 ): Promise<GetCollectionsResponse> { 155 - this.requireAuthentication('getMyCollections'); 156 132 return this.queryClient.getMyCollections(params); 157 133 } 158 134 ··· 165 141 async getUrlStatusForMyLibrary( 166 142 params: GetUrlStatusForMyLibraryParams, 167 143 ): Promise<GetUrlStatusForMyLibraryResponse> { 168 - this.requireAuthentication('getUrlStatusForMyLibrary'); 169 144 return this.queryClient.getUrlStatusForMyLibrary(params); 170 145 } 171 146 ··· 187 162 return this.queryClient.getCollectionsForUrl(params); 188 163 } 189 164 190 - // Card operations - delegate to CardClient (all require authentication) 165 + // Card operations - delegate to CardClient 191 166 async addUrlToLibrary( 192 167 request: AddUrlToLibraryRequest, 193 168 ): Promise<AddUrlToLibraryResponse> { 194 - this.requireAuthentication('addUrlToLibrary'); 195 169 return this.cardClient.addUrlToLibrary(request); 196 170 } 197 171 198 172 async addCardToLibrary( 199 173 request: AddCardToLibraryRequest, 200 174 ): Promise<AddCardToLibraryResponse> { 201 - this.requireAuthentication('addCardToLibrary'); 202 175 return this.cardClient.addCardToLibrary(request); 203 176 } 204 177 205 178 async addCardToCollection( 206 179 request: AddCardToCollectionRequest, 207 180 ): Promise<AddCardToCollectionResponse> { 208 - this.requireAuthentication('addCardToCollection'); 209 181 return this.cardClient.addCardToCollection(request); 210 182 } 211 183 212 184 async updateNoteCard( 213 185 request: UpdateNoteCardRequest, 214 186 ): Promise<UpdateNoteCardResponse> { 215 - this.requireAuthentication('updateNoteCard'); 216 187 return this.cardClient.updateNoteCard(request); 217 188 } 218 189 ··· 226 197 async removeCardFromLibrary( 227 198 request: RemoveCardFromLibraryRequest, 228 199 ): Promise<RemoveCardFromLibraryResponse> { 229 - this.requireAuthentication('removeCardFromLibrary'); 230 200 return this.cardClient.removeCardFromLibrary(request); 231 201 } 232 202 233 203 async removeCardFromCollection( 234 204 request: RemoveCardFromCollectionRequest, 235 205 ): Promise<RemoveCardFromCollectionResponse> { 236 - this.requireAuthentication('removeCardFromCollection'); 237 206 return this.cardClient.removeCardFromCollection(request); 238 207 } 239 208 240 - // Collection operations - delegate to CollectionClient (all require authentication) 209 + // Collection operations - delegate to CollectionClient 241 210 async createCollection( 242 211 request: CreateCollectionRequest, 243 212 ): Promise<CreateCollectionResponse> { 244 - this.requireAuthentication('createCollection'); 245 213 return this.collectionClient.createCollection(request); 246 214 } 247 215 248 216 async updateCollection( 249 217 request: UpdateCollectionRequest, 250 218 ): Promise<UpdateCollectionResponse> { 251 - this.requireAuthentication('updateCollection'); 252 219 return this.collectionClient.updateCollection(request); 253 220 } 254 221 255 222 async deleteCollection( 256 223 request: DeleteCollectionRequest, 257 224 ): Promise<DeleteCollectionResponse> { 258 - this.requireAuthentication('deleteCollection'); 259 225 return this.collectionClient.deleteCollection(request); 260 226 } 261 227 ··· 281 247 async refreshAccessToken( 282 248 request: RefreshAccessTokenRequest, 283 249 ): Promise<RefreshAccessTokenResponse> { 284 - this.requireAuthentication('refreshAccessToken'); 285 250 return this.userClient.refreshAccessToken(request); 286 251 } 287 252 288 253 async generateExtensionTokens( 289 254 request?: GenerateExtensionTokensRequest, 290 255 ): Promise<GenerateExtensionTokensResponse> { 291 - this.requireAuthentication('generateExtensionTokens'); 292 256 return this.userClient.generateExtensionTokens(request); 293 257 } 294 258 295 259 async logout(): Promise<{ success: boolean; message: string }> { 296 - this.requireAuthentication('logout'); 297 260 return this.userClient.logout(); 298 261 } 299 262 ··· 309 272 export * from './types'; 310 273 311 274 // Factory functions for different client types 312 - export const createAuthenticatedApiClient = () => { 313 - return new ApiClient( 314 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 315 - createClientTokenManager(), 316 - ); 317 - }; 318 - 319 - export const createUnauthenticatedApiClient = () => { 275 + export const createApiClient = () => { 320 276 return new ApiClient( 321 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 322 - undefined, 277 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 323 278 ); 324 279 }; 325 280 326 - // Factory function for server-side API client 327 - export const createServerApiClient = async () => { 328 - const tokenManager = await createServerTokenManager(); 329 - return new ApiClient( 330 - process.env.API_BASE_URL || 'http://localhost:3000', 331 - tokenManager, 332 - ); 281 + export const createServerApiClient = () => { 282 + return new ApiClient(process.env.API_BASE_URL || 'http://127.0.0.1:3000'); 333 283 }; 334 284 335 - // Default authenticated client instance for client-side usage (backward compatibility) 336 - export const apiClient = createAuthenticatedApiClient(); 337 - 338 - // Default unauthenticated client instance for public operations 339 - export const publicApiClient = createUnauthenticatedApiClient(); 285 + // Default client instance for backward compatibility 286 + export const apiClient = createApiClient();
+14 -43
src/webapp/api-client/clients/BaseClient.ts
··· 1 1 import { ApiError, ApiErrorResponse } from '../types/errors'; 2 - import { TokenManager } from '../../services/TokenManager'; 3 2 4 3 export abstract class BaseClient { 5 - constructor( 6 - protected baseUrl: string, 7 - protected tokenManager?: TokenManager, 8 - ) {} 4 + constructor(protected baseUrl: string) {} 9 5 10 6 protected async request<T>( 11 7 method: string, 12 8 endpoint: string, 13 9 data?: any, 14 10 ): Promise<T> { 15 - const makeRequest = async (): Promise<T> => { 16 - const url = `${this.baseUrl}${endpoint}`; 17 - const token = this.tokenManager 18 - ? await this.tokenManager.getAccessToken() 19 - : null; 11 + const url = `${this.baseUrl}${endpoint}`; 20 12 21 - const headers: Record<string, string> = { 22 - 'Content-Type': 'application/json', 23 - }; 24 - 25 - if (token) { 26 - headers['Authorization'] = `Bearer ${token}`; 27 - } 13 + const headers: Record<string, string> = { 14 + 'Content-Type': 'application/json', 15 + }; 28 16 29 - const config: RequestInit = { 30 - method, 31 - headers, 32 - }; 33 - 34 - if ( 35 - data && 36 - (method === 'POST' || method === 'PUT' || method === 'PATCH') 37 - ) { 38 - config.body = JSON.stringify(data); 39 - } 40 - 41 - const response = await fetch(url, config); 42 - return this.handleResponse<T>(response); 17 + const config: RequestInit = { 18 + method, 19 + headers, 20 + credentials: 'include', // Include cookies automatically (works for both client and server) 43 21 }; 44 22 45 - try { 46 - return await makeRequest(); 47 - } catch (error) { 48 - // Handle 401/403 errors with automatic token refresh (only if we have a token manager) 49 - if ( 50 - this.tokenManager && 51 - error instanceof ApiError && 52 - (error.status === 401 || error.status === 403) 53 - ) { 54 - return this.tokenManager.handleAuthError(makeRequest); 55 - } 56 - throw error; 23 + if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { 24 + config.body = JSON.stringify(data); 57 25 } 26 + 27 + const response = await fetch(url, config); 28 + return this.handleResponse<T>(response); 58 29 } 59 30 60 31 private async handleResponse<T>(response: Response): Promise<T> {
+1 -5
src/webapp/api-client/clients/UserClient.ts
··· 71 71 } 72 72 73 73 async logout(): Promise<{ success: boolean; message: string }> { 74 - if (!this.tokenManager) { 75 - throw new Error('TokenManager is required for logout'); 76 - } 77 - const refreshToken = await this.tokenManager.getRefreshToken(); 74 + // With cookie-based auth, refreshToken is sent automatically via cookies 78 75 return this.request<{ success: boolean; message: string }>( 79 76 'POST', 80 77 '/api/users/logout', 81 - { refreshToken }, 82 78 ); 83 79 } 84 80 }
+11 -23
src/webapp/app/(auth)/auth/complete/page.tsx
··· 2 2 3 3 import { useEffect, useState, Suspense } from 'react'; 4 4 import { useRouter, useSearchParams } from 'next/navigation'; 5 - import { useAuth } from '@/hooks/useAuth'; 6 5 import { ExtensionService } from '@/services/extensionService'; 7 6 import { ApiClient } from '@/api-client/ApiClient'; 8 - import { createClientTokenManager } from '@/services/auth'; 9 7 import { Card, Center, Loader, Stack, Title, Text } from '@mantine/core'; 10 8 11 9 function AuthCompleteContent() { 12 10 const [message, setMessage] = useState('Processing your login...'); 13 11 const router = useRouter(); 14 12 const searchParams = useSearchParams(); 15 - const { setTokens } = useAuth(); 16 13 17 14 useEffect(() => { 18 15 const handleAuth = async () => { 19 16 // Create API client instance 20 17 const apiClient = new ApiClient( 21 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 22 - createClientTokenManager(), 18 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 23 19 ); 24 20 25 - const accessToken = searchParams.get('accessToken'); 26 - const refreshToken = searchParams.get('refreshToken'); 27 21 const error = searchParams.get('error'); 28 22 29 - // Clear the URL parameters for security 30 - const cleanUrl = '/'; 31 - window.history.replaceState({}, document.title, cleanUrl); 32 - 23 + // Check for error parameter 33 24 if (error) { 34 25 console.error('Authentication error:', error); 35 26 router.push(`/login?error=${encodeURIComponent(error)}`); ··· 57 48 } 58 49 }; 59 50 60 - if (accessToken && refreshToken) { 61 - // Store tokens using the auth context function 62 - await setTokens(accessToken, refreshToken); 51 + // With cookie-based auth, tokens are automatically set in cookies by the backend 52 + // No need to handle tokens from URL parameters anymore 53 + setMessage('Authentication successful!'); 63 54 64 - // Check if extension tokens were requested 65 - if (ExtensionService.isExtensionTokensRequested()) { 66 - handleExtensionTokenGeneration(); 67 - } else { 68 - // Redirect to home 69 - router.push('/home'); 70 - } 55 + // Check if extension tokens were requested 56 + if (ExtensionService.isExtensionTokensRequested()) { 57 + handleExtensionTokenGeneration(); 71 58 } else { 72 - router.push('/login?error=Authentication failed'); 59 + // Redirect to home after a brief moment 60 + setTimeout(() => router.push('/home'), 500); 73 61 } 74 62 }; 75 63 76 64 handleAuth(); 77 - }, [router, searchParams, setTokens]); 65 + }, [router, searchParams]); 78 66 79 67 return ( 80 68 <Stack align="center">
+1 -3
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/cards/layout.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import type { Metadata } from 'next'; 4 3 import { Fragment } from 'react'; 5 4 ··· 12 11 const { handle } = await params; 13 12 14 13 const apiClient = new ApiClient( 15 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 16 - createClientTokenManager(), 14 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 17 15 ); 18 16 19 17 const profile = await apiClient.getProfile({
+1 -3
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/collections/layout.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import type { Metadata } from 'next'; 4 3 import { Fragment } from 'react'; 5 4 ··· 12 11 const { handle } = await params; 13 12 14 13 const apiClient = new ApiClient( 15 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 16 - createClientTokenManager(), 14 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 17 15 ); 18 16 19 17 const profile = await apiClient.getProfile({
+1 -3
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/layout.tsx
··· 5 5 import { Box, Container } from '@mantine/core'; 6 6 import { Fragment, Suspense } from 'react'; 7 7 import { ApiClient } from '@/api-client/ApiClient'; 8 - import { createClientTokenManager } from '@/services/auth'; 9 8 import ProfileHeaderSkeleton from '@/features/profile/components/profileHeader/Skeleton.ProfileHeader'; 10 9 11 10 interface Props { ··· 17 16 const { handle } = await params; 18 17 19 18 const apiClient = new ApiClient( 20 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 21 - createClientTokenManager(), 19 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 22 20 ); 23 21 24 22 const profile = await apiClient.getProfile({
+1 -3
src/webapp/app/(dashboard)/profile/[handle]/(withHeader)/opengraph-image.tsx
··· 1 1 import { ApiClient } from '@/api-client'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import OpenGraphCard from '@/features/openGraph/components/openGraphCard/OpenGraphCard'; 4 3 import { truncateText } from '@/lib/utils/text'; 5 4 ··· 17 16 const { handle } = await props.params; 18 17 19 18 const apiClient = new ApiClient( 20 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 21 - createClientTokenManager(), 19 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 22 20 ); 23 21 24 22 const profile = await apiClient.getProfile({ identifier: handle });
+1 -3
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/layout.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 2 import Header from '@/components/navigation/header/Header'; 3 - import { createClientTokenManager } from '@/services/auth'; 4 3 import type { Metadata } from 'next'; 5 4 import { Fragment } from 'react'; 6 5 ··· 12 11 const { rkey, handle } = await params; 13 12 14 13 const apiClient = new ApiClient( 15 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 16 - createClientTokenManager(), 14 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 17 15 ); 18 16 19 17 const collection = await apiClient.getCollectionPageByAtUri({
+1 -3
src/webapp/app/(dashboard)/profile/[handle]/(withoutHeader)/collections/[rkey]/opengraph-image.tsx
··· 1 1 import { ApiClient } from '@/api-client'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import OpenGraphCard from '@/features/openGraph/components/openGraphCard/OpenGraphCard'; 4 3 import { truncateText } from '@/lib/utils/text'; 5 4 ··· 17 16 const { rkey, handle } = await props.params; 18 17 19 18 const apiClient = new ApiClient( 20 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 21 - createClientTokenManager(), 19 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 22 20 ); 23 21 24 22 const collection = await apiClient.getCollectionPageByAtUri({
+146
src/webapp/app/api/auth/me/route.ts
··· 1 + import { NextRequest, NextResponse } from 'next/server'; 2 + import { cookies } from 'next/headers'; 3 + 4 + // Helper to check if token is expired or will expire soon 5 + function isTokenExpiringSoon( 6 + token: string | null | undefined, 7 + bufferMinutes: number = 5, 8 + ): boolean { 9 + if (!token) return true; 10 + 11 + try { 12 + const payload = JSON.parse( 13 + Buffer.from(token.split('.')[1], 'base64').toString(), 14 + ); 15 + const expiry = payload.exp * 1000; 16 + const bufferTime = bufferMinutes * 60 * 1000; 17 + return Date.now() >= expiry - bufferTime; 18 + } catch { 19 + return true; 20 + } 21 + } 22 + 23 + export async function GET(request: NextRequest) { 24 + try { 25 + const cookieStore = await cookies(); 26 + let accessToken = cookieStore.get('accessToken')?.value; 27 + const refreshToken = cookieStore.get('refreshToken')?.value; 28 + 29 + // No tokens at all - not authenticated 30 + if (!accessToken && !refreshToken) { 31 + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); 32 + } 33 + 34 + // Check if accessToken is expired or expiring soon (< 5 min) 35 + if (isTokenExpiringSoon(accessToken, 5) && refreshToken) { 36 + try { 37 + // Call backend to refresh tokens 38 + const backendUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000'; 39 + const refreshResponse = await fetch( 40 + `${backendUrl}/api/users/oauth/refresh`, 41 + { 42 + method: 'POST', 43 + headers: { 44 + 'Content-Type': 'application/json', 45 + Cookie: `refreshToken=${refreshToken}`, 46 + }, 47 + body: JSON.stringify({ refreshToken }), 48 + }, 49 + ); 50 + 51 + if (!refreshResponse.ok) { 52 + // Refresh failed - clear cookies and return 401 53 + const response = NextResponse.json( 54 + { error: 'Token refresh failed' }, 55 + { status: 401 }, 56 + ); 57 + response.cookies.delete('accessToken'); 58 + response.cookies.delete('refreshToken'); 59 + return response; 60 + } 61 + 62 + const newTokens = await refreshResponse.json(); 63 + accessToken = newTokens.accessToken; 64 + 65 + // Fetch profile with new token 66 + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { 67 + method: 'GET', 68 + headers: { 69 + 'Content-Type': 'application/json', 70 + Cookie: `accessToken=${accessToken}`, 71 + }, 72 + }); 73 + 74 + if (!profileResponse.ok) { 75 + return NextResponse.json( 76 + { error: 'Failed to fetch profile' }, 77 + { status: profileResponse.status }, 78 + ); 79 + } 80 + 81 + const user = await profileResponse.json(); 82 + 83 + // Create response with user profile and set new cookies 84 + const response = NextResponse.json({ user }); 85 + 86 + response.cookies.set('accessToken', newTokens.accessToken, { 87 + httpOnly: true, 88 + secure: process.env.NODE_ENV === 'production', 89 + sameSite: 'strict', 90 + maxAge: parseInt(process.env.ACCESS_TOKEN_EXPIRES_IN || '3600'), // Default 1 hour 91 + path: '/', 92 + }); 93 + 94 + response.cookies.set('refreshToken', newTokens.refreshToken, { 95 + httpOnly: true, 96 + secure: process.env.NODE_ENV === 'production', 97 + sameSite: 'strict', 98 + maxAge: parseInt(process.env.REFRESH_TOKEN_EXPIRES_IN || '2592000'), // Default 30 days 99 + path: '/', 100 + }); 101 + 102 + return response; 103 + } catch (error) { 104 + console.error('Token refresh error:', error); 105 + return NextResponse.json( 106 + { error: 'Authentication failed' }, 107 + { status: 500 }, 108 + ); 109 + } 110 + } 111 + 112 + // AccessToken is valid - fetch profile 113 + try { 114 + const backendUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000'; 115 + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { 116 + method: 'GET', 117 + headers: { 118 + 'Content-Type': 'application/json', 119 + Cookie: `accessToken=${accessToken}`, 120 + }, 121 + }); 122 + 123 + if (!profileResponse.ok) { 124 + return NextResponse.json( 125 + { error: 'Failed to fetch profile' }, 126 + { status: profileResponse.status }, 127 + ); 128 + } 129 + 130 + const user = await profileResponse.json(); 131 + return NextResponse.json({ user }); 132 + } catch (error) { 133 + console.error('Profile fetch error:', error); 134 + return NextResponse.json( 135 + { error: 'Failed to fetch profile' }, 136 + { status: 500 }, 137 + ); 138 + } 139 + } catch (error) { 140 + console.error('Auth me error:', error); 141 + return NextResponse.json( 142 + { error: 'Internal server error' }, 143 + { status: 500 }, 144 + ); 145 + } 146 + }
+3 -7
src/webapp/components/AddToCollectionModal.tsx
··· 1 1 'use client'; 2 2 3 3 import { useState, useEffect, useMemo, useCallback } from 'react'; 4 - import { createClientTokenManager } from '@/services/auth'; 5 4 import { ApiClient } from '@/api-client/ApiClient'; 6 5 import { Button, Group, Modal, Stack, Text } from '@mantine/core'; 7 6 import { CollectionSelector } from './CollectionSelector'; ··· 37 36 38 37 // Create API client instance 39 38 const apiClient = new ApiClient( 40 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 41 - createClientTokenManager(), 39 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 42 40 ); 43 41 44 42 // Get existing collections for this card ··· 50 48 const fetchCard = useCallback(async () => { 51 49 // Create API client instance 52 50 const apiClient = new ApiClient( 53 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 54 - createClientTokenManager(), 51 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 55 52 ); 56 53 57 54 try { ··· 85 82 try { 86 83 // Create API client instance 87 84 const apiClient = new ApiClient( 88 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 89 - createClientTokenManager(), 85 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 90 86 ); 91 87 92 88 // Add card to all selected collections in a single request
+1 -3
src/webapp/components/extension/SaveCardPage.tsx
··· 13 13 Title, 14 14 } from '@mantine/core'; 15 15 import { UrlCardForm } from '../UrlCardForm'; 16 - import { createExtensionTokenManager } from '@/services/auth'; 17 16 18 17 export function SaveCardPage() { 19 18 const { logout, user } = useExtensionAuth(); ··· 24 23 const apiClient = useMemo( 25 24 () => 26 25 new ApiClient( 27 - process.env.PLASMO_PUBLIC_API_URL || 'http://localhost:3000', 28 - createExtensionTokenManager(), 26 + process.env.PLASMO_PUBLIC_API_URL || 'http://127.0.0.1:3000', 29 27 ), 30 28 [], 31 29 );
+1 -1
src/webapp/components/extension/SignInPage.tsx
··· 10 10 const [loginError, setLoginError] = useState(''); 11 11 12 12 const handleSignIn = () => { 13 - const appUrl = process.env.PLASMO_PUBLIC_APP_URL || 'http://localhost:3000'; 13 + const appUrl = process.env.PLASMO_PUBLIC_APP_URL || 'http://127.0.0.1:3000'; 14 14 const loginUrl = `${appUrl}/login?extension-login=true`; 15 15 chrome.tabs.create({ url: loginUrl }); 16 16 window.close();
+8 -10
src/webapp/features/auth/components/loginForm/LoginForm.tsx
··· 14 14 import { BiRightArrowAlt } from 'react-icons/bi'; 15 15 import { MdOutlineAlternateEmail, MdLock } from 'react-icons/md'; 16 16 import { useAuth } from '@/hooks/useAuth'; 17 - import { createClientTokenManager } from '@/services/auth'; 18 17 import { useForm } from '@mantine/form'; 19 18 import { useRouter, useSearchParams } from 'next/navigation'; 20 19 import { useEffect, useState } from 'react'; ··· 22 21 export default function LoginForm() { 23 22 const router = useRouter(); 24 23 const searchParams = useSearchParams(); 25 - const { setTokens, isAuthenticated } = useAuth(); 24 + const { isAuthenticated, refreshAuth } = useAuth(); 26 25 27 26 const [isCheckingAuth, setIsCheckingAuth] = useState(true); 28 27 const [isLoading, setIsLoading] = useState(false); ··· 30 29 31 30 const isExtensionLogin = searchParams.get('extension-login') === 'true'; 32 31 const apiClient = new ApiClient( 33 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 34 - createClientTokenManager(), 32 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 35 33 ); 36 34 37 35 const handleExtensionTokenGeneration = async () => { ··· 107 105 setIsLoading(true); 108 106 setError(''); 109 107 110 - const { accessToken, refreshToken } = 111 - await apiClient.loginWithAppPassword({ 112 - identifier: form.values.handle, 113 - appPassword: form.values.appPassword, 114 - }); 108 + await apiClient.loginWithAppPassword({ 109 + identifier: form.values.handle, 110 + appPassword: form.values.appPassword, 111 + }); 115 112 116 - await setTokens(accessToken, refreshToken); 113 + // Refresh auth state to fetch user profile with new tokens (cookies are set automatically) 114 + await refreshAuth(); 117 115 118 116 if (isExtensionLogin) { 119 117 await handleExtensionTokenGeneration();
+1 -3
src/webapp/features/cards/lib/mutations/useAddCard.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useAddCard() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/cards/lib/mutations/useAddCardToLibrary.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useAddCardToLibrary() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/cards/lib/mutations/useRemoveCardFromCollections.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useRemoveCardFromCollections() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/cards/lib/mutations/useRemoveCardFromLibrary.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useRemoveCardFromLibrary() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/cards/lib/queries/useCards.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 9 8 10 9 export default function useCards(props: Props) { 11 10 const apiClient = new ApiClient( 12 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 13 - createClientTokenManager(), 11 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 14 12 ); 15 13 16 14 const limit = props?.limit ?? 16;
+1 -3
src/webapp/features/cards/lib/queries/useGetCard.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useGetCard(props: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const card = useSuspenseQuery({
+1 -3
src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useGetCardFromMyLibrary(props: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const cardStatus = useSuspenseQuery({
+1 -3
src/webapp/features/cards/lib/queries/useGetLibrariesForcard.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useGetLibrariesForCard(props: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const libraries = useSuspenseQuery({
+1 -3
src/webapp/features/cards/lib/queries/useMyCards.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useMyCards(props?: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const limit = props?.limit ?? 16;
+1 -3
src/webapp/features/collections/lib/mutations/useAddCardToCollection.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useAddCardToCollection() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/collections/lib/mutations/useCreateCollection.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useCreateCollection() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/collections/lib/mutations/useDeleteCollection.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useDeleteCollection() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/collections/lib/mutations/useUpdateCollection.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useUpdateCollection() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+2 -6
src/webapp/features/collections/lib/queries/useCollection.tsx
··· 1 - import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 1 + import { createApiClient } from '@/api-client/ApiClient'; 3 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 9 8 } 10 9 11 10 export default function useCollection(props: Props) { 12 - const apiClient = new ApiClient( 13 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 14 - createClientTokenManager(), 15 - ); 11 + const apiClient = createApiClient(); 16 12 17 13 const limit = props.limit ?? 20; 18 14
+1 -3
src/webapp/features/collections/lib/queries/useCollectionSearch.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 11 10 12 11 export default function useCollectionSearch(props: Props) { 13 12 const apiClient = new ApiClient( 14 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 15 - createClientTokenManager(), 13 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 16 14 ); 17 15 18 16 // TODO: replace with infinite suspense query
+1 -3
src/webapp/features/collections/lib/queries/useCollections.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 9 8 10 9 export default function useCollections(props: Props) { 11 10 const apiClient = new ApiClient( 12 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 13 - createClientTokenManager(), 11 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 14 12 ); 15 13 16 14 const limit = props?.limit ?? 15;
+1 -3
src/webapp/features/collections/lib/queries/useMyCollections.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useMyCollections(props?: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const limit = props?.limit ?? 15;
+1 -3
src/webapp/features/feeds/lib/queries/useMyFeed.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useMyFeed(props?: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const limit = props?.limit ?? 15;
+1 -3
src/webapp/features/notes/lib/mutations/useUpdateNote.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useMutation, useQueryClient } from '@tanstack/react-query'; 4 3 5 4 export default function useUpdateNote() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const queryClient = useQueryClient();
+1 -3
src/webapp/features/profile/components/profileHeader/ProfileHeader.tsx
··· 14 14 import MinimalProfileHeaderContainer from '../../containers/minimalProfileHeaderContainer/MinimalProfileHeaderContainer'; 15 15 import { FaBluesky } from 'react-icons/fa6'; 16 16 import { ApiClient } from '@/api-client/ApiClient'; 17 - import { createClientTokenManager } from '@/services/auth'; 18 17 19 18 interface Props { 20 19 handle: string; ··· 22 21 23 22 export default async function ProfileHeader(props: Props) { 24 23 const apiClient = new ApiClient( 25 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 26 - createClientTokenManager(), 24 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 27 25 ); 28 26 29 27 const profile = await apiClient.getProfile({
+1 -3
src/webapp/features/profile/lib/queries/useMyProfile.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseQuery } from '@tanstack/react-query'; 4 3 5 4 export default function useMyProfile() { 6 5 const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - createClientTokenManager(), 6 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 9 7 ); 10 8 11 9 const myProfile = useSuspenseQuery({
+1 -3
src/webapp/features/profile/lib/queries/useProfile.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 - import { createClientTokenManager } from '@/services/auth'; 3 2 import { useSuspenseQuery } from '@tanstack/react-query'; 4 3 5 4 interface Props { ··· 8 7 9 8 export default function useProfile(props: Props) { 10 9 const apiClient = new ApiClient( 11 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 - createClientTokenManager(), 10 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 13 11 ); 14 12 15 13 const profile = useSuspenseQuery({
+78 -194
src/webapp/hooks/useAuth.tsx
··· 9 9 useCallback, 10 10 } from 'react'; 11 11 import { useRouter } from 'next/navigation'; 12 - import { ApiClient, UserProfile } from '@/api-client/ApiClient'; 13 - import { 14 - getAccessToken, 15 - getRefreshToken, 16 - clearAuth, 17 - createClientTokenManager, 18 - } from '@/services/auth'; 12 + import { ClientCookieAuthService } from '@/services/auth'; 13 + import { ApiClient } from '@/api-client/ApiClient'; 14 + import type { GetProfileResponse } from '@/api-client/ApiClient'; 15 + 16 + type UserProfile = GetProfileResponse; 19 17 20 - interface AuthContextType { 18 + interface AuthState { 21 19 isAuthenticated: boolean; 22 20 isLoading: boolean; 23 - accessToken: string | null; 24 - refreshToken: string | null; 25 21 user: UserProfile | null; 22 + } 23 + 24 + interface AuthContextType extends AuthState { 26 25 login: (handle: string) => Promise<{ authUrl: string }>; 27 26 logout: () => Promise<void>; 28 - refreshTokens: () => Promise<boolean>; 29 - setTokens: (accessToken: string, refreshToken: string) => Promise<void>; 30 - revokeTokens: () => Promise<void>; 27 + refreshAuth: () => Promise<boolean>; 31 28 } 32 29 33 30 const AuthContext = createContext<AuthContextType | undefined>(undefined); 34 31 35 32 export const AuthProvider = ({ children }: { children: ReactNode }) => { 36 - const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false); 37 - const [isLoading, setIsLoading] = useState<boolean>(true); 38 - const [accessToken, setAccessToken] = useState<string | null>(null); 39 - const [refreshToken, setRefreshToken] = useState<string | null>(null); 40 - const [user, setUser] = useState<UserProfile | null>(null); 41 - const router = useRouter(); 42 - 43 - // Create API client instance 44 - const createApiClient = useCallback(() => { 45 - return new ApiClient( 46 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 47 - createClientTokenManager(), 48 - ); 49 - }, []); 33 + const [authState, setAuthState] = useState<AuthState>({ 34 + isAuthenticated: false, 35 + isLoading: true, 36 + user: null, 37 + }); 50 38 51 - // Helper function to check if a JWT token is expired 52 - const isTokenExpired = (token: string): boolean => { 53 - if (!token) return true; 54 - 55 - try { 56 - const payload = JSON.parse(atob(token.split('.')[1])); 57 - const expiry = payload.exp * 1000; // Convert to milliseconds 58 - return Date.now() >= expiry; 59 - } catch (e) { 60 - return true; 61 - } 62 - }; 39 + const router = useRouter(); 63 40 64 - const handleLogout = useCallback(async () => { 41 + // Refresh authentication (fetch user profile with automatic token refresh) 42 + const refreshAuth = useCallback(async (): Promise<boolean> => { 65 43 try { 66 - // Call logout endpoint to revoke refresh token 67 - const apiClient = createApiClient(); 68 - await apiClient.logout(); 69 - } catch (error) { 70 - console.error('Logout error:', error); 71 - // Continue with logout even if API call fails 72 - } finally { 73 - revokeTokens(); 44 + // Call /api/auth/me which handles token refresh + profile fetch 45 + // HttpOnly cookies sent automatically with credentials: 'include' 46 + const response = await fetch('/api/auth/me', { 47 + method: 'GET', 48 + credentials: 'include', 49 + }); 74 50 75 - // Redirect to login 76 - router.push('/login'); 77 - } 78 - }, [router, createApiClient]); 51 + if (!response.ok) { 52 + setAuthState({ 53 + isAuthenticated: false, 54 + user: null, 55 + isLoading: false, 56 + }); 57 + return false; 58 + } 79 59 80 - const refreshTokens = useCallback(async (): Promise<boolean> => { 81 - if (!refreshToken) return false; 60 + const { user } = await response.json(); 82 61 83 - try { 84 - const apiClient = createApiClient(); 85 - const { accessToken: newAccessToken, refreshToken: newRefreshToken } = 86 - await apiClient.refreshAccessToken({ refreshToken }); 62 + setAuthState({ 63 + isAuthenticated: true, 64 + user, 65 + isLoading: false, 66 + }); 87 67 88 - await setTokens(newAccessToken, newRefreshToken); 89 68 return true; 90 69 } catch (error) { 91 - console.error('Token refresh failed:', error); 92 - handleLogout(); 70 + console.error('Auth refresh failed:', error); 71 + setAuthState({ 72 + isAuthenticated: false, 73 + user: null, 74 + isLoading: false, 75 + }); 93 76 return false; 94 77 } 95 - }, [refreshToken, createApiClient, handleLogout]); 96 - 97 - // Initialize auth state from stored tokens 98 - useEffect(() => { 99 - const initializeAuth = async () => { 100 - const storedAccessToken = getAccessToken(); 101 - const storedRefreshToken = getRefreshToken(); 102 - 103 - if (storedAccessToken && storedRefreshToken) { 104 - setAccessToken(storedAccessToken); 105 - setRefreshToken(storedRefreshToken); 106 - setIsAuthenticated(true); 107 - 108 - try { 109 - // If token is expired, refresh it first 110 - if (isTokenExpired(storedAccessToken)) { 111 - const refreshSuccess = await refreshTokens(); 112 - if (!refreshSuccess) { 113 - throw new Error('Token refresh failed'); 114 - } 115 - } 116 - 117 - // Fetch user profile 118 - const apiClient = createApiClient(); 119 - const userData = await apiClient.getMyProfile(); 120 - setUser(userData); 121 - } catch (error) { 122 - console.error('Error initializing auth:', error); 123 - handleLogout(); 124 - } 125 - } 126 - 127 - setIsLoading(false); 128 - }; 129 - 130 - initializeAuth(); 131 78 }, []); 132 79 133 - // Helper function to check if a JWT token is expired or will expire soon 134 - const isTokenExpiredWithBuffer = ( 135 - token: string, 136 - bufferMinutes: number = 5, 137 - ): boolean => { 138 - if (!token) return true; 139 - 140 - try { 141 - const payload = JSON.parse(atob(token.split('.')[1])); 142 - const expiry = payload.exp * 1000; // Convert to milliseconds 143 - const bufferTime = bufferMinutes * 60 * 1000; // Buffer in milliseconds 144 - return Date.now() >= expiry - bufferTime; 145 - } catch (e) { 146 - return true; 147 - } 148 - }; 149 - 150 - // Proactive token refresh 80 + // Initialize auth on mount 151 81 useEffect(() => { 152 - if (!accessToken || !refreshToken) return; 82 + refreshAuth(); 83 + }, [refreshAuth]); 153 84 154 - const checkAndRefreshToken = async () => { 155 - if (isTokenExpiredWithBuffer(accessToken, 10)) { 156 - await refreshTokens(); 157 - } 158 - }; 159 - 160 - // Check immediately 161 - checkAndRefreshToken(); 85 + // Periodic auth check (every 5 minutes) 86 + useEffect(() => { 87 + if (!authState.isAuthenticated) return; 162 88 163 - const interval = setInterval(checkAndRefreshToken, 5 * 60 * 1000); 89 + const interval = setInterval( 90 + async () => { 91 + await refreshAuth(); 92 + }, 93 + 5 * 60 * 1000, 94 + ); // Check every 5 minutes 164 95 165 96 return () => clearInterval(interval); 166 - }, [accessToken, refreshToken, refreshTokens]); 167 - 168 - const login = useCallback( 169 - async (handle: string) => { 170 - try { 171 - const apiClient = createApiClient(); 172 - return await apiClient.initiateOAuthSignIn({ handle }); 173 - } catch (error) { 174 - console.error('Login error:', error); 175 - throw error; 176 - } 177 - }, 178 - [createApiClient], 179 - ); 180 - 181 - const setTokens = useCallback( 182 - async (accessToken: string, refreshToken: string) => { 183 - // Store tokens in localStorage 184 - localStorage.setItem('accessToken', accessToken); 185 - localStorage.setItem('refreshToken', refreshToken); 186 - 187 - // Update state 188 - setAccessToken(accessToken); 189 - setRefreshToken(refreshToken); 190 - setIsAuthenticated(true); 191 - 192 - // Sync tokens with server-side cookies 193 - try { 194 - await fetch('/api/auth/sync', { 195 - method: 'POST', 196 - headers: { 'Content-Type': 'application/json' }, 197 - body: JSON.stringify({ 198 - accessToken, 199 - refreshToken, 200 - }), 201 - credentials: 'include', // Important for cookie handling 202 - }); 203 - } catch (error) { 204 - console.error('Failed to sync tokens with server:', error); 205 - // Don't throw error - localStorage tokens are still valid 206 - } 207 - }, 208 - [], 209 - ); 97 + }, [authState.isAuthenticated, refreshAuth]); 210 98 211 - const revokeTokens = useCallback(async () => { 212 - // Clear auth state 213 - clearAuth(); 214 - setAccessToken(null); 215 - setRefreshToken(null); 216 - setUser(null); 217 - setIsAuthenticated(false); 99 + const login = useCallback(async (handle: string) => { 100 + const apiClient = new ApiClient( 101 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000', 102 + ); 103 + return await apiClient.initiateOAuthSignIn({ handle }); 104 + }, []); 218 105 219 - // Clear server-side cookies 106 + const logout = useCallback(async () => { 220 107 try { 221 - await fetch('/api/auth/logout', { 222 - method: 'POST', 223 - credentials: 'include', // Important for cookie handling 108 + await ClientCookieAuthService.clearTokens(); 109 + } catch (error) { 110 + console.error('Logout error:', error); 111 + } finally { 112 + setAuthState({ 113 + isAuthenticated: false, 114 + isLoading: false, 115 + user: null, 224 116 }); 225 - } catch (error) { 226 - console.error('Failed to clear server-side cookies:', error); 227 - // Don't throw error - localStorage tokens are already cleared 117 + router.push('/login'); 228 118 } 229 - }, []); 119 + }, [router]); 230 120 231 121 return ( 232 122 <AuthContext.Provider 233 123 value={{ 234 - isAuthenticated, 235 - isLoading, 236 - accessToken, 237 - refreshToken, 238 - user, 124 + ...authState, 239 125 login, 240 - logout: handleLogout, 241 - refreshTokens, 242 - setTokens, 243 - revokeTokens, 126 + logout, 127 + refreshAuth, 244 128 }} 245 129 > 246 130 {children} ··· 250 134 251 135 export const useAuth = () => { 252 136 const context = useContext(AuthContext); 253 - if (context === undefined) { 137 + if (!context) { 254 138 throw new Error('useAuth must be used within an AuthProvider'); 255 139 } 256 140 return context;
+1 -3
src/webapp/hooks/useExtensionAuth.tsx
··· 7 7 useCallback, 8 8 } from 'react'; 9 9 import { ApiClient } from '@/api-client/ApiClient'; 10 - import { createExtensionTokenManager } from '@/services/auth'; 11 10 12 11 interface ExtensionAuthContextType { 13 12 isAuthenticated: boolean; ··· 36 35 37 36 const createApiClient = useCallback((token: string | null) => { 38 37 return new ApiClient( 39 - process.env.PLASMO_PUBLIC_API_URL || 'http://localhost:3000', 40 - createExtensionTokenManager(), 38 + process.env.PLASMO_PUBLIC_API_URL || 'http://127.0.0.1:3000', 41 39 ); 42 40 }, []); 43 41
+55
src/webapp/lib/serverAuth.ts
··· 1 + import { ServerCookieAuthService } from '@/services/auth/CookieAuthService.server'; 2 + import type { GetProfileResponse } from '@/api-client/ApiClient'; 3 + 4 + type UserProfile = GetProfileResponse; 5 + 6 + export async function getServerAuthStatus(): Promise<{ 7 + isAuthenticated: boolean; 8 + user: UserProfile | null; 9 + error: string | null; 10 + }> { 11 + try { 12 + const { accessToken } = await ServerCookieAuthService.getTokens(); 13 + 14 + if (!accessToken || ServerCookieAuthService.isTokenExpired(accessToken)) { 15 + return { 16 + isAuthenticated: false, 17 + user: null, 18 + error: 'No valid access token', 19 + }; 20 + } 21 + 22 + // Make direct API call with cookie header for server-side 23 + const baseUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000'; 24 + const response = await fetch(`${baseUrl}/api/users/me`, { 25 + method: 'GET', 26 + headers: { 27 + 'Content-Type': 'application/json', 28 + Cookie: `accessToken=${accessToken}`, 29 + }, 30 + cache: 'no-store', 31 + }); 32 + 33 + if (!response.ok) { 34 + return { 35 + isAuthenticated: false, 36 + user: null, 37 + error: `API request failed: ${response.status}`, 38 + }; 39 + } 40 + 41 + const user: UserProfile = await response.json(); 42 + 43 + return { 44 + isAuthenticated: true, 45 + user, 46 + error: null, 47 + }; 48 + } catch (error: any) { 49 + return { 50 + isAuthenticated: false, 51 + user: null, 52 + error: error.message || 'Authentication failed', 53 + }; 54 + } 55 + }
-118
src/webapp/services/TokenManager.ts
··· 1 - export interface AuthTokens { 2 - accessToken: string | null; 3 - refreshToken: string | null; 4 - } 5 - 6 - export interface TokenStorage { 7 - getTokens(): AuthTokens; 8 - setTokens(accessToken: string, refreshToken: string): Promise<void>; 9 - clearTokens(): void; 10 - } 11 - 12 - export interface TokenRefresher { 13 - refreshTokens( 14 - refreshToken: string, 15 - ): Promise<{ accessToken: string; refreshToken: string }>; 16 - } 17 - 18 - export class TokenManager { 19 - private isRefreshing = false; 20 - private refreshPromise: Promise<boolean> | null = null; 21 - private failedRequestsQueue: Array<{ 22 - resolve: (value: any) => void; 23 - reject: (error: any) => void; 24 - request: () => Promise<any>; 25 - }> = []; 26 - 27 - constructor( 28 - private storage: TokenStorage, 29 - private refresher: TokenRefresher, 30 - ) {} 31 - 32 - async getAccessToken(): Promise<string | null> { 33 - const { accessToken } = this.storage.getTokens(); 34 - return accessToken; 35 - } 36 - 37 - async getRefreshToken(): Promise<string | null> { 38 - const { refreshToken } = this.storage.getTokens(); 39 - return refreshToken; 40 - } 41 - 42 - async handleAuthError<T>(originalRequest: () => Promise<T>): Promise<T> { 43 - // If already refreshing, queue this request 44 - if (this.isRefreshing) { 45 - return new Promise((resolve, reject) => { 46 - this.failedRequestsQueue.push({ 47 - resolve, 48 - reject, 49 - request: originalRequest, 50 - }); 51 - }); 52 - } 53 - 54 - // Start refresh process 55 - this.isRefreshing = true; 56 - 57 - try { 58 - // Use existing refresh promise or create new one 59 - if (!this.refreshPromise) { 60 - this.refreshPromise = this.performRefresh(); 61 - } 62 - 63 - const refreshSuccess = await this.refreshPromise; 64 - 65 - if (refreshSuccess) { 66 - // Process queued requests 67 - const queuedRequests = [...this.failedRequestsQueue]; 68 - this.failedRequestsQueue = []; 69 - 70 - // Retry all queued requests 71 - queuedRequests.forEach(async ({ resolve, reject, request }) => { 72 - try { 73 - const result = await request(); 74 - resolve(result); 75 - } catch (error) { 76 - reject(error); 77 - } 78 - }); 79 - 80 - // Retry original request 81 - return await originalRequest(); 82 - } else { 83 - // Refresh failed, reject all queued requests 84 - const queuedRequests = [...this.failedRequestsQueue]; 85 - this.failedRequestsQueue = []; 86 - 87 - const refreshError = new Error('Token refresh failed'); 88 - queuedRequests.forEach(({ reject }) => reject(refreshError)); 89 - 90 - if (typeof window !== 'undefined') { 91 - window.location.href = '/login'; 92 - } 93 - throw refreshError; 94 - } 95 - } finally { 96 - this.isRefreshing = false; 97 - this.refreshPromise = null; 98 - } 99 - } 100 - 101 - private async performRefresh(): Promise<boolean> { 102 - const { refreshToken } = this.storage.getTokens(); 103 - if (!refreshToken) return false; 104 - 105 - try { 106 - const newTokens = await this.refresher.refreshTokens(refreshToken); 107 - await this.storage.setTokens( 108 - newTokens.accessToken, 109 - newTokens.refreshToken, 110 - ); 111 - return true; 112 - } catch (error) { 113 - console.error('Token refresh failed:', error); 114 - this.storage.clearTokens(); 115 - return false; 116 - } 117 - } 118 - }
-21
src/webapp/services/TokenRefresher.ts
··· 1 - import { TokenRefresher } from './TokenManager'; 2 - 3 - export class ApiTokenRefresher implements TokenRefresher { 4 - constructor(private baseUrl: string) {} 5 - 6 - async refreshTokens( 7 - refreshToken: string, 8 - ): Promise<{ accessToken: string; refreshToken: string }> { 9 - const response = await fetch(`${this.baseUrl}/api/users/oauth/refresh`, { 10 - method: 'POST', 11 - headers: { 'Content-Type': 'application/json' }, 12 - body: JSON.stringify({ refreshToken }), 13 - }); 14 - 15 - if (!response.ok) { 16 - throw new Error('Token refresh failed'); 17 - } 18 - 19 - return response.json(); 20 - } 21 - }
+2 -78
src/webapp/services/auth.ts
··· 2 2 * Authentication utilities for the client-side application 3 3 */ 4 4 5 - import { TokenManager } from './TokenManager'; 6 - import { ClientTokenStorage } from './storage/ClientTokenStorage'; 7 - import { ServerTokenStorage } from './storage/ServerTokenStorage'; 8 - import { ApiTokenRefresher } from './TokenRefresher'; 9 - 10 - // Check if the user is authenticated 11 - export const isAuthenticated = (): boolean => { 12 - if (typeof window === 'undefined') { 13 - return false; // Not authenticated in server-side context 14 - } 15 - 16 - const accessToken = localStorage.getItem('accessToken'); 17 - return !!accessToken; 18 - }; 19 - 20 - // Get the access token 21 - export const getAccessToken = (): string | null => { 22 - if (typeof window === 'undefined') { 23 - return null; 24 - } 25 - 26 - return localStorage.getItem('accessToken'); 27 - }; 28 - 29 - export const getAccessTokenInServerComponent = async (): Promise< 30 - string | null 31 - > => { 32 - // Get access token on server side 33 - const { cookies } = await import('next/headers'); 34 - const cookiesStore = await cookies(); 35 - const accessToken = cookiesStore.get('accessToken')?.value || null; 36 - return accessToken; 37 - }; 38 - 39 - // Get the refresh token 40 - export const getRefreshToken = (): string | null => { 41 - if (typeof window === 'undefined') { 42 - return null; 43 - } 44 - 45 - return localStorage.getItem('refreshToken'); 46 - }; 47 - 48 - // Clear authentication tokens (logout) 49 - export const clearAuth = (): void => { 50 - if (typeof window === 'undefined') { 51 - return; 52 - } 53 - 54 - localStorage.removeItem('accessToken'); 55 - localStorage.removeItem('refreshToken'); 56 - }; 57 - 58 - // Client-side token manager 59 - export const createClientTokenManager = () => { 60 - const storage = new ClientTokenStorage(); 61 - const refresher = new ApiTokenRefresher( 62 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 63 - ); 64 - return new TokenManager(storage, refresher); 65 - }; 66 - 67 - // Server-side token manager (read-only, no refresh capability) 68 - export const createServerTokenManager = async () => { 69 - const { cookies } = await import('next/headers'); 70 - const cookiesStore = await cookies(); 71 - const storage = new ServerTokenStorage(cookiesStore); 72 - const refresher = new ApiTokenRefresher(''); // Won't be used 73 - return new TokenManager(storage, refresher); 74 - }; 75 - 76 - export const createExtensionTokenManager = () => { 77 - const storage = new ClientTokenStorage(); 78 - const refresher = new ApiTokenRefresher( 79 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 80 - ); 81 - return new TokenManager(storage, refresher); 82 - }; 5 + // Re-export cookie auth services 6 + export { ClientCookieAuthService } from './auth/CookieAuthService.client';
+26
src/webapp/services/auth/CookieAuthService.client.ts
··· 1 + export class ClientCookieAuthService { 2 + // Note: With HttpOnly cookies, we cannot read tokens from document.cookie 3 + // The browser automatically sends cookies with requests using credentials: 'include' 4 + // All auth logic (checking status, refreshing tokens) is handled by /api/auth/me endpoint 5 + 6 + // Set cookies via API (used after OAuth login) 7 + static async setTokens( 8 + accessToken: string, 9 + refreshToken: string, 10 + ): Promise<void> { 11 + await fetch('/api/auth/sync', { 12 + method: 'POST', 13 + headers: { 'Content-Type': 'application/json' }, 14 + body: JSON.stringify({ accessToken, refreshToken }), 15 + credentials: 'include', 16 + }); 17 + } 18 + 19 + // Clear cookies via API (logout) 20 + static async clearTokens(): Promise<void> { 21 + await fetch('/api/auth/logout', { 22 + method: 'POST', 23 + credentials: 'include', 24 + }); 25 + } 26 + }
+34
src/webapp/services/auth/CookieAuthService.server.ts
··· 1 + import { cookies } from 'next/headers'; 2 + 3 + export interface AuthTokens { 4 + accessToken: string | null; 5 + refreshToken: string | null; 6 + } 7 + 8 + export class ServerCookieAuthService { 9 + // Server-side: read from Next.js cookies 10 + static async getTokens(): Promise<AuthTokens> { 11 + const cookieStore = await cookies(); 12 + return { 13 + accessToken: cookieStore.get('accessToken')?.value || null, 14 + refreshToken: cookieStore.get('refreshToken')?.value || null, 15 + }; 16 + } 17 + 18 + // Check if token is expired (same logic as client) 19 + static isTokenExpired( 20 + token: string | null, 21 + bufferMinutes: number = 5, 22 + ): boolean { 23 + if (!token) return true; 24 + 25 + try { 26 + const payload = JSON.parse(atob(token.split('.')[1])); 27 + const expiry = payload.exp * 1000; 28 + const bufferTime = bufferMinutes * 60 * 1000; 29 + return Date.now() >= expiry - bufferTime; 30 + } catch { 31 + return true; 32 + } 33 + } 34 + }
+6
src/webapp/services/auth/index.ts
··· 1 + // Client-side exports 2 + export { ClientCookieAuthService } from './CookieAuthService.client'; 3 + 4 + // Server-side exports 5 + export { ServerCookieAuthService } from './CookieAuthService.server'; 6 + export type { AuthTokens } from './CookieAuthService.server';
-39
src/webapp/services/storage/ClientTokenStorage.ts
··· 1 - import { AuthTokens, TokenStorage } from '../TokenManager'; 2 - 3 - export class ClientTokenStorage implements TokenStorage { 4 - getTokens(): AuthTokens { 5 - if (typeof window === 'undefined') { 6 - return { accessToken: null, refreshToken: null }; 7 - } 8 - 9 - return { 10 - accessToken: localStorage.getItem('accessToken'), 11 - refreshToken: localStorage.getItem('refreshToken'), 12 - }; 13 - } 14 - 15 - async setTokens(accessToken: string, refreshToken: string): Promise<void> { 16 - if (typeof window === 'undefined') return; 17 - 18 - localStorage.setItem('accessToken', accessToken); 19 - localStorage.setItem('refreshToken', refreshToken); 20 - 21 - // Sync with server cookies 22 - try { 23 - await fetch('/api/auth/sync', { 24 - method: 'POST', 25 - headers: { 'Content-Type': 'application/json' }, 26 - body: JSON.stringify({ accessToken, refreshToken }), 27 - credentials: 'include', 28 - }); 29 - } catch (error) { 30 - console.warn('Failed to sync tokens with server:', error); 31 - } 32 - } 33 - 34 - clearTokens(): void { 35 - if (typeof window === 'undefined') return; 36 - localStorage.removeItem('accessToken'); 37 - localStorage.removeItem('refreshToken'); 38 - } 39 - }
-22
src/webapp/services/storage/ServerTokenStorage.ts
··· 1 - import { AuthTokens, TokenStorage } from '../TokenManager'; 2 - 3 - export class ServerTokenStorage implements TokenStorage { 4 - constructor(private cookiesStore: any) {} 5 - 6 - getTokens(): AuthTokens { 7 - return { 8 - accessToken: this.cookiesStore.get('accessToken')?.value || null, 9 - refreshToken: this.cookiesStore.get('refreshToken')?.value || null, 10 - }; 11 - } 12 - 13 - async setTokens(): Promise<void> { 14 - // Server-side can't set tokens - handled by client-side sync 15 - throw new Error('Server-side token refresh not supported'); 16 - } 17 - 18 - clearTokens(): void { 19 - // Server-side can't clear tokens directly 20 - throw new Error('Server-side token clearing not supported'); 21 - } 22 - }