diff --git a/src/accounting-app/app.ts b/src/accounting-app/app.ts index 704dbab..eb20adf 100644 --- a/src/accounting-app/app.ts +++ b/src/accounting-app/app.ts @@ -15,26 +15,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -/** - * Accept an auth token from a URL parameter (e.g. ?token=xxx). - * This allows the main app to link users directly into Castle - * without requiring a separate login. The token is stored in - * localStorage and the parameter is stripped from the URL. - */ -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - // Also persist user data key so auth service picks it up - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Castle] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' /** * Initialize the standalone Castle accounting app @@ -43,7 +24,7 @@ export async function createAppInstance() { console.log('Starting Castle — Accounting App...') // Accept token from URL before anything else (cross-subdomain auth relay) - acceptTokenFromUrl() + acceptTokenFromUrl('Castle') const app = createApp(App) diff --git a/src/activities-app/app.ts b/src/activities-app/app.ts index e2bc818..581a6e2 100644 --- a/src/activities-app/app.ts +++ b/src/activities-app/app.ts @@ -14,24 +14,7 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -/** - * Accept an auth token from a URL parameter (e.g. ?token=xxx). - * Allows the main app to link users directly into this standalone - * app without requiring a separate login. - */ -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Sortir] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' /** * Initialize the standalone activities app @@ -40,7 +23,7 @@ export async function createAppInstance() { console.log('🚀 Starting Sortir — Activities App...') // Accept token from URL before anything else (cross-subdomain auth relay) - acceptTokenFromUrl() + acceptTokenFromUrl('Sortir') const app = createApp(App) diff --git a/src/chat-app/app.ts b/src/chat-app/app.ts index f107399..d4b2573 100644 --- a/src/chat-app/app.ts +++ b/src/chat-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Chat] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Chat app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Chat') const app = createApp(App) diff --git a/src/forum-app/app.ts b/src/forum-app/app.ts index 66d6d19..b1e1d4c 100644 --- a/src/forum-app/app.ts +++ b/src/forum-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Forum] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Forum app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Forum') const app = createApp(App) diff --git a/src/lib/api/lnbits.ts b/src/lib/api/lnbits.ts index 1166cc8..c837861 100644 --- a/src/lib/api/lnbits.ts +++ b/src/lib/api/lnbits.ts @@ -195,6 +195,35 @@ export class LnbitsAPI extends BaseService { return !!this.accessToken } + /** + * Server-validate a token and adopt it if valid (issue #36). + * + * Called by AuthService.checkAuth() when a pending URL-supplied token is + * found in localStorage. We can't trust the token until the server has + * confirmed it represents a real session, so: + * 1. Temporarily set the candidate token on the API client + * 2. Try getCurrentUser() with it + * 3. On success → persist to AUTH_TOKEN_KEY, return the user + * 4. On failure → restore the previous token (if any), return null + * + * The pending token is the caller's responsibility to remove from + * localStorage afterwards. + */ + async tryAdoptToken(candidateToken: string): Promise { + const previousToken = this.accessToken + this.accessToken = candidateToken + try { + const user = await this.getCurrentUser() + // Server confirmed — persist for future page loads + setAuthToken(candidateToken) + return user + } catch (err) { + console.warn('[LnbitsAPI] Pending URL token rejected by server:', err) + this.accessToken = previousToken + return null + } + } + getAccessToken(): string | null { return this.accessToken } diff --git a/src/lib/config/lnbits.ts b/src/lib/config/lnbits.ts index 5d6ce0c..dec6c8e 100644 --- a/src/lib/config/lnbits.ts +++ b/src/lib/config/lnbits.ts @@ -13,6 +13,11 @@ export const LNBITS_CONFIG = { // Auth token storage key AUTH_TOKEN_KEY: 'lnbits_access_token', + // Transient key for tokens received via ?token=… URL params. They live here + // until validateAndAdoptPendingToken() server-checks them; only validated + // tokens get promoted to AUTH_TOKEN_KEY. See issue #36. + PENDING_AUTH_TOKEN_KEY: 'lnbits_pending_token', + // User storage key USER_STORAGE_KEY: 'lnbits_user_data' } @@ -42,4 +47,20 @@ export function setAuthToken(token: string): void { // Helper function to remove auth token from storage export function removeAuthToken(): void { localStorage.removeItem(LNBITS_CONFIG.AUTH_TOKEN_KEY) -} +} + +// Pending token (URL-supplied, unvalidated) helpers. +// Pending tokens land here from acceptTokenFromUrl() and only get promoted +// to the real AUTH_TOKEN_KEY after server validation. +export function getPendingAuthToken(): string | null { + return localStorage.getItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY) +} + +export function setPendingAuthToken(token: string): void { + localStorage.setItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY, token) +} + +export function removePendingAuthToken(): void { + localStorage.removeItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY) +} + diff --git a/src/lib/router-helpers.ts b/src/lib/router-helpers.ts index b0cac33..c23bf9d 100644 --- a/src/lib/router-helpers.ts +++ b/src/lib/router-helpers.ts @@ -14,7 +14,14 @@ import type { Router, RouteRecordRaw } from 'vue-router' * mismatch: guards register early but await this promise before reading * auth state. Phase 3 calls markAuthReady() once auth is initialized. */ -type AuthLike = { isAuthenticated: { value: boolean } } +type AuthUserLike = { value: { pubkey?: string } | null } +type AuthLike = { + isAuthenticated: { value: boolean } + // Populated after server-validated getCurrentUser() in auth.checkAuth(). + // Guards require BOTH isAuthenticated and a user with a pubkey — token + // presence alone is not enough (issue #36). + currentUser: AuthUserLike +} let resolveAuth!: (a: AuthLike) => void const authReady: Promise = new Promise(r => { resolveAuth = r }) @@ -23,6 +30,15 @@ export function markAuthReady(auth: AuthLike): void { resolveAuth(auth) } +/** + * Belt-and-suspenders auth check: token presence in localStorage isn't + * sufficient — the server must have confirmed the token represents a real + * session, which is signalled by currentUser being populated with a pubkey. + */ +function isFullyAuthed(auth: AuthLike): boolean { + return auth.isAuthenticated.value && !!auth.currentUser?.value?.pubkey +} + /** * Strict guard — every non-/login route requires auth. * Used by wallet, chat, castle (no public view). @@ -30,10 +46,11 @@ export function markAuthReady(auth: AuthLike): void { export function installStrictAuthGuard(router: Router): void { router.beforeEach(async (to) => { const auth = await authReady + const authed = isFullyAuthed(auth) if (to.path === '/login') { - return auth.isAuthenticated.value ? '/' : true + return authed ? '/' : true } - return auth.isAuthenticated.value ? true : '/login' + return authed ? true : '/login' }) } @@ -45,8 +62,9 @@ export function installLenientAuthGuard(router: Router): void { router.beforeEach(async (to) => { const auth = await authReady const requiresAuth = to.meta.requiresAuth === true - if (requiresAuth && !auth.isAuthenticated.value) return '/login' - if (to.path === '/login' && auth.isAuthenticated.value) return '/' + const authed = isFullyAuthed(auth) + if (requiresAuth && !authed) return '/login' + if (to.path === '/login' && authed) return '/' return true }) } diff --git a/src/lib/url-token.ts b/src/lib/url-token.ts new file mode 100644 index 0000000..01ee5a8 --- /dev/null +++ b/src/lib/url-token.ts @@ -0,0 +1,27 @@ +import { setPendingAuthToken } from '@/lib/config/lnbits' + +/** + * Cross-subdomain auth relay (issue #36): pull `?token=…` off the URL into + * the pending-token slot in localStorage, then strip it from history so it + * doesn't bleed into bookmarks or referrers. + * + * The token is NOT promoted to the real auth-token slot here. AuthService + * .checkAuth() server-validates it via lnbitsAPI.tryAdoptToken() and only + * persists it if the LNbits backend confirms it represents a real session. + * + * Call this synchronously at app boot, before createApp(), so the URL is + * cleaned before vue-router has a chance to read it. The pending token sits + * in localStorage until auth.initialize() picks it up later in the same + * page load. + */ +export function acceptTokenFromUrl(appName: string): void { + const params = new URLSearchParams(window.location.search) + const token = params.get('token') + if (!token) return + setPendingAuthToken(token) + params.delete('token') + const clean = params.toString() + const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash + window.history.replaceState({}, '', newUrl) + console.log(`[${appName}] URL token captured for server validation`) +} diff --git a/src/market-app/app.ts b/src/market-app/app.ts index 9b7e936..103ce53 100644 --- a/src/market-app/app.ts +++ b/src/market-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Market] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Market app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Market') const app = createApp(App) diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts index 9f64931..7b4b6fe 100644 --- a/src/modules/base/auth/auth-service.ts +++ b/src/modules/base/auth/auth-service.ts @@ -5,6 +5,7 @@ import { eventBus } from '@/core/event-bus' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits' import type { NostrMetadataService } from '../nostr/nostr-metadata-service' +import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits' export class AuthService extends BaseService { // Service metadata @@ -49,6 +50,28 @@ export class AuthService extends BaseService { } async checkAuth(): Promise { + // Pending URL-supplied token (from acceptTokenFromUrl in app shells). + // Validate server-side before promoting to the real auth-token slot — + // see issue #36. Always remove the pending entry whether validation + // succeeds or fails so it can't recur on later boots. + const pending = getPendingAuthToken() + if (pending) { + removePendingAuthToken() + this.isLoading.value = true + try { + const adopted = await this.lnbitsAPI.tryAdoptToken(pending) + if (adopted) { + this.user.value = adopted + this.isAuthenticated.value = true + this.debug(`Adopted pending URL token for ${adopted.username || adopted.id}`) + return true + } + this.debug('Pending URL token rejected — falling through to existing token') + } finally { + this.isLoading.value = false + } + } + if (!this.lnbitsAPI.isAuthenticated()) { this.debug('No auth token found - user needs to login') this.isAuthenticated.value = false @@ -59,14 +82,14 @@ export class AuthService extends BaseService { try { this.isLoading.value = true const userData = await this.lnbitsAPI.getCurrentUser() - + this.user.value = userData this.isAuthenticated.value = true - + this.debug(`User authenticated: ${userData.username || userData.id} (${userData.pubkey?.slice(0, 8)})`) - + return true - + } catch (error) { this.handleError(error, 'checkAuth') this.isAuthenticated.value = false diff --git a/src/modules/market/views/MarketDashboard.vue b/src/modules/market/views/MarketDashboard.vue index 44aac34..ded7018 100644 --- a/src/modules/market/views/MarketDashboard.vue +++ b/src/modules/market/views/MarketDashboard.vue @@ -62,7 +62,7 @@ diff --git a/src/tasks-app/app.ts b/src/tasks-app/app.ts index 25fce53..2223842 100644 --- a/src/tasks-app/app.ts +++ b/src/tasks-app/app.ts @@ -14,24 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Tasks] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Tasks app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Tasks') const app = createApp(App) diff --git a/src/wallet-app/app.ts b/src/wallet-app/app.ts index 07d1cef..e9fc6ae 100644 --- a/src/wallet-app/app.ts +++ b/src/wallet-app/app.ts @@ -14,28 +14,12 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers' - -/** - * Accept an auth token from a URL parameter (e.g. ?token=xxx). - * Allows the hub to link users into Wallet without re-login. - */ -function acceptTokenFromUrl() { - const params = new URLSearchParams(window.location.search) - const token = params.get('token') - if (token) { - localStorage.setItem('lnbits_access_token', token) - params.delete('token') - const clean = params.toString() - const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash - window.history.replaceState({}, '', newUrl) - console.log('[Wallet] Auth token accepted from URL') - } -} +import { acceptTokenFromUrl } from '@/lib/url-token' export async function createAppInstance() { console.log('Starting Wallet app...') - acceptTokenFromUrl() + acceptTokenFromUrl('Wallet') const app = createApp(App)