@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { Code , ConnectError , createClient , type Interceptor } from "@connectrpc/connect" ;
import { createConnectTransport } from "@connectrpc/connect-web" ;
import { getAccessToken , setAccessToken } from "./auth-state" ;
import { ROUTES } from "./router/routes" ;
import { instanceStore } from "./store" ;
import { ActivityService } from "./types/proto/api/v1/activity_service_pb" ;
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb" ;
import { AuthService } from "./types/proto/api/v1/auth_service_pb" ;
@ -11,64 +13,93 @@ import { MemoService } from "./types/proto/api/v1/memo_service_pb";
import { ShortcutService } from "./types/proto/api/v1/shortcut_service_pb" ;
import { UserService } from "./types/proto/api/v1/user_service_pb" ;
let isRefreshing = false ;
let refreshPromise : Promise < void > | null = null ;
// ============================================================================
// Constants
// ============================================================================
/ * *
* Authentication interceptor that :
* 1 . Attaches access token to outgoing requests
* 2 . Handles 401 Unauthenticated errors by refreshing the token
* 3 . Retries the original request with the new token
* 4 . Redirects to login if refresh fails
* /
const authInterceptor : Interceptor = ( next ) = > async ( req ) = > {
// Add access token to request if available
const token = getAccessToken ( ) ;
if ( token ) {
req . header . set ( "Authorization" , ` Bearer ${ token } ` ) ;
}
const RETRY_HEADER = "X-Retry" ;
const RETRY_HEADER_VALUE = "true" ;
try {
return await next ( req ) ;
} catch ( error ) {
// Only handle ConnectError with Unauthenticated code
if ( error instanceof ConnectError && error . code === Code . Unauthenticated && ! req . header . get ( "X-Retry" ) ) {
// Prevent concurrent refresh attempts
if ( ! isRefreshing ) {
isRefreshing = true ;
refreshPromise = refreshAccessToken ( ) ;
}
const ROUTE_CONFIG = {
// Routes accessible without authentication (uses prefix matching)
public : [
ROUTES . AUTH , // Authentication pages
ROUTES . EXPLORE , // Explore page
"/u/" , // User profile pages (dynamic)
"/memos/" , // Individual memo detail pages (dynamic)
] ,
try {
await refreshPromise ;
isRefreshing = false ;
refreshPromise = null ;
// Retry with new token
const newToken = getAccessToken ( ) ;
if ( newToken ) {
req . header . set ( "Authorization" , ` Bearer ${ newToken } ` ) ;
req . header . set ( "X-Retry" , "true" ) ;
return await next ( req ) ;
}
} catch ( refreshError ) {
isRefreshing = false ;
refreshPromise = null ;
// Refresh failed - redirect to login (only if not already there)
if ( ! window . location . pathname . startsWith ( "/auth" ) ) {
window . location . href = "/auth" ;
}
throw refreshError ;
}
// Routes that require authentication (uses exact matching)
private : [ ROUTES . ROOT , ROUTES . ATTACHMENTS , ROUTES . INBOX , ROUTES . ARCHIVED , ROUTES . SETTING ] ,
} as const ;
// ============================================================================
// Token Refresh State Management
// ============================================================================
class TokenRefreshManager {
private isRefreshing = false ;
private refreshPromise : Promise < void > | null = null ;
async refresh ( refreshFn : ( ) = > Promise < void > ) : Promise < void > {
if ( this . isRefreshing && this . refreshPromise ) {
return this . refreshPromise ;
}
throw error ;
this . isRefreshing = true ;
this . refreshPromise = refreshFn ( ) . finally ( ( ) = > {
this . isRefreshing = false ;
this . refreshPromise = null ;
} ) ;
return this . refreshPromise ;
}
} ;
/ * *
* Custom fetch that includes credentials for cookie handling .
* Required for HttpOnly refresh token cookie to be sent / received .
* /
isCurrentlyRefreshing ( ) : boolean {
return this . isRefreshing ;
}
}
const tokenRefreshManager = new TokenRefreshManager ( ) ;
// ============================================================================
// Route Access Control
// ============================================================================
function isPublicRoute ( path : string ) : boolean {
return ROUTE_CONFIG . public . some ( ( route ) = > path . startsWith ( route ) ) ;
}
function isPrivateRoute ( path : string ) : boolean {
return ( ROUTE_CONFIG . private as readonly string [ ] ) . includes ( path ) ;
}
function getAuthFailureRedirect ( currentPath : string ) : string | null {
if ( isPublicRoute ( currentPath ) ) {
return null ;
}
if ( instanceStore . state . memoRelatedSetting . disallowPublicVisibility ) {
return ROUTES . AUTH ;
}
if ( isPrivateRoute ( currentPath ) ) {
return ROUTES . EXPLORE ;
}
return null ;
}
function performRedirect ( redirectUrl : string | null ) : void {
if ( redirectUrl ) {
window . location . href = redirectUrl ;
}
}
// ============================================================================
// Token Refresh
// ============================================================================
const fetchWithCredentials : typeof globalThis . fetch = ( input , init ) = > {
return globalThis . fetch ( input , {
. . . init ,
@ -76,33 +107,75 @@ const fetchWithCredentials: typeof globalThis.fetch = (input, init) => {
} ) ;
} ;
/ * *
* Separate transport for refresh token operations .
* Uses no auth interceptor to avoid circular dependency when the main
* interceptor triggers a refresh .
* /
// Separate transport without auth interceptor to prevent recursion
const refreshTransport = createConnectTransport ( {
baseUrl : window.location.origin ,
useBinaryFormat : true ,
fetch : fetchWithCredentials ,
interceptors : [ ] , // No interceptors to avoid recursion
interceptors : [ ] ,
} ) ;
// Dedicated auth client for refresh operations only
const refreshAuthClient = createClient ( AuthService , refreshTransport ) ;
/ * *
* Refreshes the access token using the HttpOnly refresh token cookie .
* Called automatically by the auth interceptor when requests fail with 401 .
* /
async function refreshAccessToken ( ) : Promise < void > {
const response = await refreshAuthClient . refreshToken ( { } ) ;
setAccessToken ( response . accessToken , response . expiresAt ? timestampDate ( response . expiresAt ) : undefined ) ;
if ( ! response . accessToken ) {
throw new ConnectError ( "Refresh token response missing access token" , Code . Internal ) ;
}
const expiresAt = response . expiresAt ? timestampDate ( response . expiresAt ) : undefined ;
setAccessToken ( response . accessToken , expiresAt ) ;
}
/ * *
* Main transport for all API requests .
* /
// ============================================================================
// Authentication Interceptor
// ============================================================================
const authInterceptor : Interceptor = ( next ) = > async ( req ) = > {
const token = getAccessToken ( ) ;
if ( token ) {
req . header . set ( "Authorization" , ` Bearer ${ token } ` ) ;
}
try {
return await next ( req ) ;
} catch ( error ) {
if ( ! ( error instanceof ConnectError ) ) {
throw error ;
}
if ( error . code !== Code . Unauthenticated ) {
throw error ;
}
if ( req . header . get ( RETRY_HEADER ) === RETRY_HEADER_VALUE ) {
throw error ;
}
try {
await tokenRefreshManager . refresh ( refreshAccessToken ) ;
const newToken = getAccessToken ( ) ;
if ( ! newToken ) {
throw new ConnectError ( "Token refresh succeeded but no token available" , Code . Internal ) ;
}
req . header . set ( "Authorization" , ` Bearer ${ newToken } ` ) ;
req . header . set ( RETRY_HEADER , RETRY_HEADER_VALUE ) ;
return await next ( req ) ;
} catch ( refreshError ) {
const redirectUrl = getAuthFailureRedirect ( window . location . pathname ) ;
performRedirect ( redirectUrl ) ;
throw refreshError ;
}
}
} ;
// ============================================================================
// Transport & Service Clients
// ============================================================================
const transport = createConnectTransport ( {
baseUrl : window.location.origin ,
useBinaryFormat : true ,