chore: update CHANGELOG.md to include improvements in authentication flow, addressing CSRF handling and cookie management for seamless sign-in and enhanced security
This commit is contained in:
@@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Seamless sign-in from Auth.** When you click "Sign in" on Pulse and complete authentication in the Ciphera Auth portal, you now return to Pulse fully authenticated without any loading loops or errors. We fixed CSRF handling and cookie forwarding issues that were causing 403 errors after OAuth callback, so the transition between apps is now smooth and reliable.
|
||||
- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired.
|
||||
- **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days.
|
||||
- **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA.
|
||||
|
||||
@@ -91,6 +91,20 @@ export async function exchangeAuthCode(code: string, codeVerifier: string | null
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
})
|
||||
|
||||
// * Note: CSRF token should be set by Auth API login flow and available via cookie
|
||||
// * If the Auth API returns a CSRF token in header, we forward it
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
if (csrfToken) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function POST() {
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// * Get CSRF token from Auth API response header (for cookie rotation)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
|
||||
cookieStore.set('access_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
@@ -55,6 +58,18 @@ export async function POST() {
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
|
||||
// * Set/update CSRF token cookie (non-httpOnly, for JS access)
|
||||
if (csrfToken) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, access_token: data.access_token })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -22,6 +22,36 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
|
||||
return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * CSRF Token Handling
|
||||
// * ============================================================================
|
||||
|
||||
/**
|
||||
* Get CSRF token from the csrf_token cookie (non-httpOnly)
|
||||
* This is needed for state-changing requests to the Auth API
|
||||
*/
|
||||
function getCSRFToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const cookies = document.cookie.split(';')
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=')
|
||||
if (name === 'csrf_token') {
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request method requires CSRF protection
|
||||
* State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens
|
||||
*/
|
||||
function isStateChangingMethod(method: string): boolean {
|
||||
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH']
|
||||
return stateChangingMethods.includes(method.toUpperCase())
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data?: Record<string, unknown>
|
||||
@@ -150,13 +180,29 @@ async function apiRequest<T>(
|
||||
? `${baseUrl}${endpoint}`
|
||||
: `${baseUrl}/api/v1${endpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
// * Merge any additional headers from options
|
||||
if (options.headers) {
|
||||
const additionalHeaders = options.headers as Record<string, string>
|
||||
Object.entries(additionalHeaders).forEach(([key, value]) => {
|
||||
headers[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
|
||||
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
|
||||
|
||||
// * Add CSRF token for state-changing requests to Auth API
|
||||
// * Auth API uses Double Submit Cookie pattern for CSRF protection
|
||||
if (isAuthRequest && isStateChangingMethod(method)) {
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
|
||||
Reference in New Issue
Block a user