@@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
22import { Code , ConnectError , createClient , type Interceptor } from "@connectrpc/connect" ;
33import { createConnectTransport } from "@connectrpc/connect-web" ;
44import { getAccessToken , setAccessToken } from "./auth-state" ;
5+ import { ROUTES } from "./router/routes" ;
6+ import { instanceStore } from "./store" ;
57import { ActivityService } from "./types/proto/api/v1/activity_service_pb" ;
68import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb" ;
79import { AuthService } from "./types/proto/api/v1/auth_service_pb" ;
@@ -11,98 +13,169 @@ import { MemoService } from "./types/proto/api/v1/memo_service_pb";
1113import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb" ;
1214import { UserService } from "./types/proto/api/v1/user_service_pb" ;
1315
14- let isRefreshing = false ;
15- let refreshPromise : Promise < void > | null = null ;
16+ // ============================================================================
17+ // Constants
18+ // ============================================================================
1619
17- /**
18- * Authentication interceptor that:
19- * 1. Attaches access token to outgoing requests
20- * 2. Handles 401 Unauthenticated errors by refreshing the token
21- * 3. Retries the original request with the new token
22- * 4. Redirects to login if refresh fails
23- */
24- const authInterceptor : Interceptor = ( next ) => async ( req ) => {
25- // Add access token to request if available
26- const token = getAccessToken ( ) ;
27- if ( token ) {
28- req . header . set ( "Authorization" , `Bearer ${ token } ` ) ;
29- }
20+ const RETRY_HEADER = "X-Retry" ;
21+ const RETRY_HEADER_VALUE = "true" ;
3022
31- try {
32- return await next ( req ) ;
33- } catch ( error ) {
34- // Only handle ConnectError with Unauthenticated code
35- if ( error instanceof ConnectError && error . code === Code . Unauthenticated && ! req . header . get ( "X-Retry" ) ) {
36- // Prevent concurrent refresh attempts
37- if ( ! isRefreshing ) {
38- isRefreshing = true ;
39- refreshPromise = refreshAccessToken ( ) ;
40- }
23+ const ROUTE_CONFIG = {
24+ // Routes accessible without authentication (uses prefix matching)
25+ public : [
26+ ROUTES . AUTH , // Authentication pages
27+ ROUTES . EXPLORE , // Explore page
28+ "/u/" , // User profile pages (dynamic)
29+ "/memos/" , // Individual memo detail pages (dynamic)
30+ ] ,
4131
42- try {
43- await refreshPromise ;
44- isRefreshing = false ;
45- refreshPromise = null ;
46-
47- // Retry with new token
48- const newToken = getAccessToken ( ) ;
49- if ( newToken ) {
50- req . header . set ( "Authorization" , `Bearer ${ newToken } ` ) ;
51- req . header . set ( "X-Retry" , "true" ) ;
52- return await next ( req ) ;
53- }
54- } catch ( refreshError ) {
55- isRefreshing = false ;
56- refreshPromise = null ;
57- // Refresh failed - redirect to login (only if not already there)
58- if ( ! window . location . pathname . startsWith ( "/auth" ) ) {
59- window . location . href = "/auth" ;
60- }
61- throw refreshError ;
62- }
32+ // Routes that require authentication (uses exact matching)
33+ private : [ ROUTES . ROOT , ROUTES . ATTACHMENTS , ROUTES . INBOX , ROUTES . ARCHIVED , ROUTES . SETTING ] ,
34+ } as const ;
35+
36+ // ============================================================================
37+ // Token Refresh State Management
38+ // ============================================================================
39+
40+ class TokenRefreshManager {
41+ private isRefreshing = false ;
42+ private refreshPromise : Promise < void > | null = null ;
43+
44+ async refresh ( refreshFn : ( ) => Promise < void > ) : Promise < void > {
45+ if ( this . isRefreshing && this . refreshPromise ) {
46+ return this . refreshPromise ;
6347 }
64- throw error ;
48+
49+ this . isRefreshing = true ;
50+ this . refreshPromise = refreshFn ( ) . finally ( ( ) => {
51+ this . isRefreshing = false ;
52+ this . refreshPromise = null ;
53+ } ) ;
54+
55+ return this . refreshPromise ;
6556 }
66- } ;
6757
68- /**
69- * Custom fetch that includes credentials for cookie handling.
70- * Required for HttpOnly refresh token cookie to be sent/received.
71- */
58+ isCurrentlyRefreshing ( ) : boolean {
59+ return this . isRefreshing ;
60+ }
61+ }
62+
63+ const tokenRefreshManager = new TokenRefreshManager ( ) ;
64+
65+ // ============================================================================
66+ // Route Access Control
67+ // ============================================================================
68+
69+ function isPublicRoute ( path : string ) : boolean {
70+ return ROUTE_CONFIG . public . some ( ( route ) => path . startsWith ( route ) ) ;
71+ }
72+
73+ function isPrivateRoute ( path : string ) : boolean {
74+ return ( ROUTE_CONFIG . private as readonly string [ ] ) . includes ( path ) ;
75+ }
76+
77+ function getAuthFailureRedirect ( currentPath : string ) : string | null {
78+ if ( isPublicRoute ( currentPath ) ) {
79+ return null ;
80+ }
81+
82+ if ( instanceStore . state . memoRelatedSetting . disallowPublicVisibility ) {
83+ return ROUTES . AUTH ;
84+ }
85+
86+ if ( isPrivateRoute ( currentPath ) ) {
87+ return ROUTES . EXPLORE ;
88+ }
89+
90+ return null ;
91+ }
92+
93+ function performRedirect ( redirectUrl : string | null ) : void {
94+ if ( redirectUrl ) {
95+ window . location . href = redirectUrl ;
96+ }
97+ }
98+
99+ // ============================================================================
100+ // Token Refresh
101+ // ============================================================================
102+
72103const fetchWithCredentials : typeof globalThis . fetch = ( input , init ) => {
73104 return globalThis . fetch ( input , {
74105 ...init ,
75106 credentials : "include" ,
76107 } ) ;
77108} ;
78109
79- /**
80- * Separate transport for refresh token operations.
81- * Uses no auth interceptor to avoid circular dependency when the main
82- * interceptor triggers a refresh.
83- */
110+ // Separate transport without auth interceptor to prevent recursion
84111const refreshTransport = createConnectTransport ( {
85112 baseUrl : window . location . origin ,
86113 useBinaryFormat : true ,
87114 fetch : fetchWithCredentials ,
88- interceptors : [ ] , // No interceptors to avoid recursion
115+ interceptors : [ ] ,
89116} ) ;
90117
91- // Dedicated auth client for refresh operations only
92118const refreshAuthClient = createClient ( AuthService , refreshTransport ) ;
93119
94- /**
95- * Refreshes the access token using the HttpOnly refresh token cookie.
96- * Called automatically by the auth interceptor when requests fail with 401.
97- */
98120async function refreshAccessToken ( ) : Promise < void > {
99121 const response = await refreshAuthClient . refreshToken ( { } ) ;
100- setAccessToken ( response . accessToken , response . expiresAt ? timestampDate ( response . expiresAt ) : undefined ) ;
122+
123+ if ( ! response . accessToken ) {
124+ throw new ConnectError ( "Refresh token response missing access token" , Code . Internal ) ;
125+ }
126+
127+ const expiresAt = response . expiresAt ? timestampDate ( response . expiresAt ) : undefined ;
128+ setAccessToken ( response . accessToken , expiresAt ) ;
101129}
102130
103- /**
104- * Main transport for all API requests.
105- */
131+ // ============================================================================
132+ // Authentication Interceptor
133+ // ============================================================================
134+
135+ const authInterceptor : Interceptor = ( next ) => async ( req ) => {
136+ const token = getAccessToken ( ) ;
137+ if ( token ) {
138+ req . header . set ( "Authorization" , `Bearer ${ token } ` ) ;
139+ }
140+
141+ try {
142+ return await next ( req ) ;
143+ } catch ( error ) {
144+ if ( ! ( error instanceof ConnectError ) ) {
145+ throw error ;
146+ }
147+
148+ if ( error . code !== Code . Unauthenticated ) {
149+ throw error ;
150+ }
151+
152+ if ( req . header . get ( RETRY_HEADER ) === RETRY_HEADER_VALUE ) {
153+ throw error ;
154+ }
155+
156+ try {
157+ await tokenRefreshManager . refresh ( refreshAccessToken ) ;
158+
159+ const newToken = getAccessToken ( ) ;
160+ if ( ! newToken ) {
161+ throw new ConnectError ( "Token refresh succeeded but no token available" , Code . Internal ) ;
162+ }
163+
164+ req . header . set ( "Authorization" , `Bearer ${ newToken } ` ) ;
165+ req . header . set ( RETRY_HEADER , RETRY_HEADER_VALUE ) ;
166+ return await next ( req ) ;
167+ } catch ( refreshError ) {
168+ const redirectUrl = getAuthFailureRedirect ( window . location . pathname ) ;
169+ performRedirect ( redirectUrl ) ;
170+ throw refreshError ;
171+ }
172+ }
173+ } ;
174+
175+ // ============================================================================
176+ // Transport & Service Clients
177+ // ============================================================================
178+
106179const transport = createConnectTransport ( {
107180 baseUrl : window . location . origin ,
108181 useBinaryFormat : true ,
0 commit comments