diff --git a/CHANGELOG.md b/CHANGELOG.md index 06558e7..d090467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.11.1-alpha] - 2026-02-23 + +### Changed + +- **Safer sign-in from the Ciphera hub.** When you open Pulse from the Ciphera Apps page, your credentials are no longer visible in the browser address bar. Sign-in now uses a secure one-time code that expires in seconds, so your session stays private even if someone sees your screen or browser history. + ## [0.11.0-alpha] - 2026-02-22 ### Added @@ -168,7 +174,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- -[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...HEAD +[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha [0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha [0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha [0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 869cfff..dc38c5f 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -33,7 +33,7 @@ interface UserPayload { /** Error type returned to client for mapping to user-facing copy (no sensitive details). */ export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server' -export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { +export async function exchangeAuthCode(code: string, codeVerifier: string | null, redirectUri: string) { try { const res = await fetch(`${AUTH_API_URL}/oauth/token`, { method: 'POST', @@ -45,7 +45,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir code, client_id: 'pulse-app', redirect_uri: redirectUri, - code_verifier: codeVerifier, + code_verifier: codeVerifier || '', }), }) diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index b2a8c34..1e69707 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -5,7 +5,7 @@ import { logger } from '@/lib/utils/logger' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/lib/auth/context' import { AUTH_URL, default as apiRequest } from '@/lib/api/client' -import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' +import { exchangeAuthCode } from '@/app/actions/auth' import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui' @@ -21,7 +21,7 @@ function AuthCallbackContent() { const code = searchParams.get('code') const codeVerifier = localStorage.getItem('oauth_code_verifier') const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : '' - if (!code || !codeVerifier) return + if (!code) return const result = await exchangeAuthCode(code, codeVerifier, redirectUri) if (result.success && result.user) { // * Fetch full profile (including display_name) before navigating so header shows correct name on first paint @@ -47,59 +47,25 @@ function AuthCallbackContent() { }, [searchParams, login, router]) useEffect(() => { - // * Prevent double execution (React Strict Mode or fast re-renders) if (processedRef.current && !isRetrying) return - // * Check for direct token passing (from auth-frontend direct login) - // * This flow exposes tokens in URL, kept for legacy support. - // * Recommended: Use Authorization Code flow (below) - const token = searchParams.get('token') - const refreshToken = searchParams.get('refresh_token') - - if (token && refreshToken) { - processedRef.current = true - const handleDirectTokens = async () => { - const result = await setSessionAction(token, refreshToken) - if (result.success && result.user) { - // * Fetch full profile (including display_name) before navigating so header shows correct name on first paint - try { - const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me') - const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role } - login(merged) - } catch { - login(result.user) - } - if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) { - router.push('/welcome') - } else { - const raw = searchParams.get('returnTo') || '/' - const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/' - router.push(safe) - } - } else { - setError(authMessageFromErrorType('invalid')) - } - } - handleDirectTokens() - return - } - const code = searchParams.get('code') + if (!code) return + const state = searchParams.get('state') - - if (!code || !state) return - const storedState = localStorage.getItem('oauth_state') const codeVerifier = localStorage.getItem('oauth_code_verifier') - if (!codeVerifier) { - setError('Missing code verifier') - return - } - if (state !== storedState) { - logger.error('State mismatch', { received: state, stored: storedState }) - setError('Invalid state') - return + // * Full OAuth flow (app-initiated): validate state + use PKCE + // * Session-authorized flow (from auth hub): no stored state or verifier + const isFullOAuth = !!storedState && !!codeVerifier + + if (isFullOAuth) { + if (state !== storedState) { + logger.error('State mismatch', { received: state, stored: storedState }) + setError('Invalid state') + return + } } processedRef.current = true diff --git a/package.json b/package.json index 022fa33..50a3275 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.11.0-alpha", + "version": "0.11.1-alpha", "private": true, "scripts": { "dev": "next dev",