From c122bbd2a5536bce320a93d52ef906f38329e051 Mon Sep 17 00:00:00 2001 From: Gerard Mc Ardle Date: Mon, 11 May 2026 13:26:18 +0200 Subject: [PATCH 1/5] feat: implement linked account handling in auth service - Updated AuthService to manage linked primary account errors, redirecting to a new 'linkedAccountBlocked' page. - Introduced a new helper for linked primary account error detection and handling. - Enhanced error handling in the HttpClient and ClientService to support linked account scenarios. - Added a new Vue component for the linked account blocked page with appropriate routing. - Updated tests to cover new functionality and ensure proper error handling. --- .../composables/authContext/useAuthService.ts | 2 +- .../composables/piniaStores/config/types.ts | 8 +- packages/web-pkg/src/helpers/auth/index.ts | 1 + .../helpers/auth/linkedPrimaryAccountError.ts | 168 ++++++++++++++++++ packages/web-pkg/src/helpers/index.ts | 1 + packages/web-pkg/src/http/client.ts | 6 + .../web-pkg/src/services/client/client.ts | 39 ++++ .../auth/linkedPrimaryAccountError.spec.ts | 161 +++++++++++++++++ .../web-runtime/src/container/bootstrap.ts | 6 +- .../src/pages/linkedAccountBlocked.vue | 130 ++++++++++++++ packages/web-runtime/src/router/index.ts | 7 + .../web-runtime/src/router/setupAuthGuard.ts | 8 +- .../src/services/auth/authService.ts | 46 +++-- .../linkedAccountBlocked.spec.ts.snap | 16 ++ .../unit/pages/linkedAccountBlocked.spec.ts | 54 ++++++ .../unit/services/auth/authService.spec.ts | 106 ++++++++++- 16 files changed, 737 insertions(+), 22 deletions(-) create mode 100644 packages/web-pkg/src/helpers/auth/index.ts create mode 100644 packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts create mode 100644 packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts create mode 100644 packages/web-runtime/src/pages/linkedAccountBlocked.vue create mode 100644 packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap create mode 100644 packages/web-runtime/tests/unit/pages/linkedAccountBlocked.spec.ts diff --git a/packages/web-pkg/src/composables/authContext/useAuthService.ts b/packages/web-pkg/src/composables/authContext/useAuthService.ts index 224533ce49d..7e35dc54549 100644 --- a/packages/web-pkg/src/composables/authContext/useAuthService.ts +++ b/packages/web-pkg/src/composables/authContext/useAuthService.ts @@ -2,7 +2,7 @@ import { useService } from '../service' import { NavigationFailure } from 'vue-router' export interface AuthServiceInterface { - handleAuthError(route: any, options?: { forceLogout?: boolean }): any + handleAuthError(route: any, options?: { forceLogout?: boolean; cause?: unknown }): any signinSilent(): Promise logoutUser(): Promise getRefreshToken(): Promise diff --git a/packages/web-pkg/src/composables/piniaStores/config/types.ts b/packages/web-pkg/src/composables/piniaStores/config/types.ts index 0860ae1555d..cfab221e17f 100644 --- a/packages/web-pkg/src/composables/piniaStores/config/types.ts +++ b/packages/web-pkg/src/composables/piniaStores/config/types.ts @@ -128,7 +128,13 @@ const OptionsConfigSchema = z.object({ hideNavigation: z.boolean().optional(), defaultLanguage: z.string().optional(), listVersions: z.boolean().optional(), - alertRwFolders: z.record(z.string()).optional() + alertRwFolders: z.record(z.string()).optional(), + linkedAccount: z + .object({ + docUrl: z.string().optional(), + userPortalUrl: z.string().optional() + }) + .optional() }) export type OptionsConfig = z.infer diff --git a/packages/web-pkg/src/helpers/auth/index.ts b/packages/web-pkg/src/helpers/auth/index.ts new file mode 100644 index 00000000000..47eae1f1864 --- /dev/null +++ b/packages/web-pkg/src/helpers/auth/index.ts @@ -0,0 +1 @@ +export * from './linkedPrimaryAccountError' diff --git a/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts b/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts new file mode 100644 index 00000000000..014df37d046 --- /dev/null +++ b/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts @@ -0,0 +1,168 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios' + +/** + * LOCAL 4348: Linked primary account detection on identity bootstrap routes only for matching HTTP responses. + * + * Post-bootstrap behaviour relies on Axios response interceptors wired from runtime (`ClientService.attachLinkedPrimaryAccountHandling`) + * on Graph, OCS, and authenticated HTTP stacks only. WebDAV and other non-Axios transports are out of scope unless backends expose the same Axios-shaped errors there. + */ + +/** + * Machine-readable codes backends may return for "linked primary account" + * on identity bootstrap routes (Graph `/me`, settings, OCS user/capabilities). + * Align error payloads with your deployment; do not treat arbitrary 409 as this case. + */ +const LINKED_PRIMARY_ERROR_CODES = new Set([ + 'linkedPrimaryAccount', + 'LinkedPrimaryAccount', + 'identity.LinkedPrimaryAccount', + 'Identity.LinkedPrimaryAccount', + 'notAllowedLinkedPrimaryAccount' +]) + +const LINKED_PRIMARY_HEADER = 'x-oc-linked-primary-account' + +/** Last resort only; prefer `error.code` or `X-Oc-Linked-Primary-Account` header from backend. */ +const MESSAGE_FRAGMENTS = ['linked primary account', 'linked primary'] + +export const LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY = 'linkedPrimaryAuthHandled' + +function resolveRequestPath(config: AxiosError['config']): string { + if (!config?.url) { + return '' + } + try { + const base = config.baseURL || 'http://localhost' + return new URL(config.url, base).pathname.toLowerCase() + } catch { + return config.url.split('?')[0].toLowerCase() + } +} + +export function isIdentityBootstrapRequestUrl(pathOrFullUrl: string): boolean { + const path = pathOrFullUrl.toLowerCase() + return ( + path.includes('/graph/v1.0/me') || + path.includes('/graph/v1beta/me') || + path.includes('/graph/v1beta1/me') || + path.includes('/api/v0/settings/') || + path.includes('/ocs/v1.php/cloud/capabilities') || + path.includes('/ocs/v1.php/cloud/users/') || + path.endsWith('/ocs/v1.php/cloud/user') + ) +} + +function readGraphStyleErrorCode(data: unknown): string | undefined { + if (!data || typeof data !== 'object') { + return undefined + } + const root = data as Record + const err = root.error + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code?: unknown }).code + return typeof code === 'string' ? code : undefined + } + return undefined +} + +function readLinkedPrimaryHeader(headers: AxiosResponse['headers']): boolean { + if (!headers) { + return false + } + let raw: string | undefined + if (typeof (headers as { get?: (n: string) => unknown }).get === 'function') { + const get = (headers as { get: (n: string) => unknown }).get + raw = get(LINKED_PRIMARY_HEADER) as string | undefined + if (raw === undefined || raw === '') { + raw = get('X-Oc-Linked-Primary-Account') as string | undefined + } + } else { + const h = headers as Record + raw = + h[LINKED_PRIMARY_HEADER] ?? + h['X-Oc-Linked-Primary-Account'] ?? + h['x-oc-linked-primary-account'] + } + return raw !== undefined && String(raw).toLowerCase() === 'true' +} + +function messageSuggestsLinkedPrimary(data: unknown): boolean { + const msg = readGraphStyleMessage(data) + if (!msg) { + return false + } + const lower = msg.toLowerCase() + return MESSAGE_FRAGMENTS.some((f) => lower.includes(f)) +} + +function readGraphStyleMessage(data: unknown): string | undefined { + if (!data || typeof data !== 'object') { + return undefined + } + const root = data as Record + const err = root.error + if (err && typeof err === 'object' && 'message' in err) { + const message = (err as { message?: unknown }).message + return typeof message === 'string' ? message : undefined + } + return undefined +} + +export function markLinkedPrimaryAuthHandled(error: unknown): void { + if (!axios.isAxiosError(error) || !error.config) { + return + } + ;(error.config as unknown as Record)[LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY] = true +} + +export function wasLinkedPrimaryAuthHandled(error: unknown): boolean { + if (!axios.isAxiosError(error) || !error.config) { + return false + } + return !!(error.config as unknown as Record)[LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY] +} + +/** + * Registers an Axios response interceptor: linked-primary 409 on bootstrap URLs invokes `onDetected`, then marks the error so duplicate `handleAuthError(..., { cause })` calls no-op. + */ +export function attachLinkedPrimaryAccountResponseInterceptor( + axiosInstance: AxiosInstance, + onDetected: (error: unknown) => void | Promise +): number { + return axiosInstance.interceptors.response.use( + (response) => response, + async (error: unknown) => { + if (isLinkedPrimaryAccountError(error)) { + await onDetected(error) + markLinkedPrimaryAuthHandled(error) + } + return Promise.reject(error) + } + ) +} + +/** + * True when the response matches the linked-primary contract on an identity + * bootstrap URL (so unrelated WebDAV or editor 409s are excluded). + */ +export function isLinkedPrimaryAccountError(err: unknown): boolean { + if (!axios.isAxiosError(err)) { + return false + } + const ae = err as AxiosError + if (ae.response?.status !== 409) { + return false + } + const path = resolveRequestPath(ae.config) + if (!isIdentityBootstrapRequestUrl(path)) { + return false + } + if (readLinkedPrimaryHeader(ae.response.headers)) { + return true + } + const code = readGraphStyleErrorCode(ae.response.data) + if (code && LINKED_PRIMARY_ERROR_CODES.has(code)) { + return true + } + return messageSuggestsLinkedPrimary(ae.response.data) +} diff --git a/packages/web-pkg/src/helpers/index.ts b/packages/web-pkg/src/helpers/index.ts index 6a316b59a7d..ba849a9ce61 100644 --- a/packages/web-pkg/src/helpers/index.ts +++ b/packages/web-pkg/src/helpers/index.ts @@ -1,3 +1,4 @@ +export * from './auth' export * from './cache' export * from './folderLink' export * from './resource' diff --git a/packages/web-pkg/src/http/client.ts b/packages/web-pkg/src/http/client.ts index 2918d88627b..483e3839ba8 100644 --- a/packages/web-pkg/src/http/client.ts +++ b/packages/web-pkg/src/http/client.ts @@ -32,6 +32,12 @@ export class HttpClient { this.cancelToken.cancel(msg) } + public useResponseErrorInterceptor( + onRejected: (error: unknown) => unknown | Promise + ): number { + return this.instance.interceptors.response.use(undefined, onRejected) + } + public async delete( url: string, data?: D, diff --git a/packages/web-pkg/src/services/client/client.ts b/packages/web-pkg/src/services/client/client.ts index 815b81f0406..62bf96fe9b0 100644 --- a/packages/web-pkg/src/services/client/client.ts +++ b/packages/web-pkg/src/services/client/client.ts @@ -10,6 +10,12 @@ import { Language } from 'vue3-gettext' import { FetchEventSourceInit } from '@microsoft/fetch-event-source' import { sse } from '@ownclouders/web-client/sse' import { AuthStore, ConfigStore } from '../../composables' +import type { AxiosInstance } from 'axios' +import { + attachLinkedPrimaryAccountResponseInterceptor, + isLinkedPrimaryAccountError, + markLinkedPrimaryAuthHandled +} from '../../helpers/auth/linkedPrimaryAccountError' const createFetchOptions = (authParams: AuthParameters, language: string): FetchEventSourceInit => { return { @@ -39,6 +45,9 @@ export class ClientService { private graphClient: Graph private ocsClient: OCS private webDavClient: WebDAV + private graphAxios: AxiosInstance | null = null + private ocsAxios: AxiosInstance | null = null + private linkedPrimaryInterceptorsAttached = false public initiatorId = uuidV4() @@ -109,12 +118,41 @@ export class ClientService { return this.webDavClient } + /** + * Registers linked-primary 409 handling on Graph, OCS, and authenticated HTTP Axios stacks (LOCAL 4348 §4.2). + * Safe to call once after runtime wires AuthService. + */ + public attachLinkedPrimaryAccountHandling( + handler: (error: unknown) => void | Promise + ): void { + if (this.linkedPrimaryInterceptorsAttached) { + return + } + this.linkedPrimaryInterceptorsAttached = true + + if (this.graphAxios) { + attachLinkedPrimaryAccountResponseInterceptor(this.graphAxios, handler) + } + if (this.ocsAxios) { + attachLinkedPrimaryAccountResponseInterceptor(this.ocsAxios, handler) + } + + this.httpAuthenticatedClient.useResponseErrorInterceptor(async (error: unknown) => { + if (isLinkedPrimaryAccountError(error)) { + await handler(error) + markLinkedPrimaryAuthHandled(error) + } + return Promise.reject(error) + }) + } + get currentLanguage() { return this.language.current } private initGraphClient() { const axiosClient = axios.create({ headers: this.staticHeaders }) + this.graphAxios = axiosClient axiosClient.interceptors.request.use((config) => { Object.assign(config.headers, this.getDynamicHeaders()) return config @@ -124,6 +162,7 @@ export class ClientService { private initOcsClient() { const axiosClient = axios.create({ headers: this.staticHeaders }) + this.ocsAxios = axiosClient axiosClient.interceptors.request.use((config) => { Object.assign(config.headers, this.getDynamicHeaders()) return config diff --git a/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts b/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts new file mode 100644 index 00000000000..613016d1bfd --- /dev/null +++ b/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts @@ -0,0 +1,161 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { describe, expect, it, vi } from 'vitest' +import { + attachLinkedPrimaryAccountResponseInterceptor, + isLinkedPrimaryAccountError, + markLinkedPrimaryAuthHandled, + wasLinkedPrimaryAuthHandled +} from '../../../../src/helpers/auth/linkedPrimaryAccountError' + +function err409(options: { + url?: string + baseURL?: string + data?: unknown + headers?: Record +}): AxiosError { + return new AxiosError( + 'conflict', + 'ERR_BAD_REQUEST', + { + url: options.url ?? '/graph/v1.0/me', + baseURL: options.baseURL ?? 'https://example.com/', + headers: {} + } as InternalAxiosRequestConfig, + {}, + { + status: 409, + statusText: 'Conflict', + data: options.data ?? { error: { code: 'linkedPrimaryAccount', message: 'x' } }, + headers: options.headers ?? {}, + config: {} as AxiosError['config'] + } + ) +} + +describe('isLinkedPrimaryAccountError', () => { + it('returns false for non-axios errors', () => { + expect(isLinkedPrimaryAccountError(new Error('oops'))).toBe(false) + }) + + it('returns false for 409 on non-bootstrap URLs', () => { + const error = err409({ + url: '/remote.php/dav/files/foo', + data: { error: { code: 'linkedPrimaryAccount' } } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(false) + }) + + it('returns false for bootstrap URL with 409 and body that does not match contract', () => { + const emptyBody = err409({ + data: {} + }) + expect(isLinkedPrimaryAccountError(emptyBody)).toBe(false) + + const otherCode = err409({ + data: { error: { code: 'generalException', message: 'something unrelated' } } + }) + expect(isLinkedPrimaryAccountError(otherCode)).toBe(false) + }) + + it('returns true for 409 on Graph /me with documented error code', () => { + expect(isLinkedPrimaryAccountError(err409({}))).toBe(true) + }) + + it('returns true when response sets X-Oc-Linked-Primary-Account header', () => { + const error = err409({ + data: {}, + headers: { 'x-oc-linked-primary-account': 'true' } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + + it('returns true for settings API path with matching code', () => { + const error = err409({ + url: '/api/v0/settings/roles-list', + data: { error: { code: 'identity.LinkedPrimaryAccount' } } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + + it('returns true for OCS user-capabilities style path with matching code', () => { + const error = err409({ + url: '/ocs/v1.php/cloud/users/account-id', + data: { error: { code: 'linkedPrimaryAccount' } } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + + it('returns true for Graph v1beta1 /me with matching code', () => { + const error = err409({ + url: '/graph/v1beta1/me', + data: { error: { code: 'linkedPrimaryAccount' } } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + + it('returns true for singular cloud/user endpoint path with matching code', () => { + const error = err409({ + url: '/ocs/v1.php/cloud/user', + data: { error: { code: 'linkedPrimaryAccount' } } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + + it('returns true when only error.message suggests linked primary on bootstrap URL', () => { + const error = err409({ + data: { + error: { + code: 'unknown', + message: 'Your linked primary account cannot access this application' + } + } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) +}) + +describe('linkedPrimaryAuthHandled marker', () => { + it('markLinkedPrimaryAuthHandled sets flag readable by wasLinkedPrimaryAuthHandled', () => { + const error = err409({}) + expect(wasLinkedPrimaryAuthHandled(error)).toBe(false) + markLinkedPrimaryAuthHandled(error) + expect(wasLinkedPrimaryAuthHandled(error)).toBe(true) + }) +}) + +describe('attachLinkedPrimaryAccountResponseInterceptor', () => { + it('registers a response interceptor that invokes onDetected then marks handled', async () => { + const handler = vi.fn().mockResolvedValue(undefined) + const client = axios.create() + const useSpy = vi.spyOn(client.interceptors.response, 'use') + + attachLinkedPrimaryAccountResponseInterceptor(client, handler) + + expect(useSpy).toHaveBeenCalled() + const onRejected = useSpy.mock.calls[0][1] as (err: unknown) => Promise + const rejection = err409({}) + + await expect(onRejected(rejection)).rejects.toBe(rejection) + + expect(handler).toHaveBeenCalledTimes(1) + expect(wasLinkedPrimaryAuthHandled(rejection)).toBe(true) + }) + + it('does not invoke handler when error is not linked-primary', async () => { + const handler = vi.fn() + const client = axios.create() + const useSpy = vi.spyOn(client.interceptors.response, 'use') + + attachLinkedPrimaryAccountResponseInterceptor(client, handler) + + const onRejected = useSpy.mock.calls[0][1] as (err: unknown) => Promise + const rejection = err409({ + url: '/remote.php/dav/files/foo', + data: { error: { code: 'linkedPrimaryAccount' } } + }) + + await expect(onRejected(rejection)).rejects.toBe(rejection) + expect(handler).not.toHaveBeenCalled() + expect(wasLinkedPrimaryAuthHandled(rejection)).toBe(false) + }) +}) diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 7514ed345c3..3cb92661ef9 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -1,7 +1,7 @@ import { registerClient } from '../services/clientRegistration' import { buildApplication, NextApplication } from './application' import { RouteLocationRaw, Router, RouteRecordNormalized } from 'vue-router' -import { App, computed, watch } from 'vue' +import { App, computed, watch, unref } from 'vue' import { loadTheme } from '../helpers/theme' import { createGettext, GetTextOptions, Language, Translations } from 'vue3-gettext' import { getBackendVersion, getWebVersion } from './versions' @@ -566,6 +566,10 @@ export const announceAuthService = ({ capabilityStore, webWorkersStore ) + ;(clientService as ClientService).attachLinkedPrimaryAccountHandling((err) => + authService.handleAuthError(unref(router.currentRoute), { cause: err }) + ) + app.config.globalProperties.$authService = authService app.provide('$authService', authService) } diff --git a/packages/web-runtime/src/pages/linkedAccountBlocked.vue b/packages/web-runtime/src/pages/linkedAccountBlocked.vue new file mode 100644 index 00000000000..f8e3e48eeca --- /dev/null +++ b/packages/web-runtime/src/pages/linkedAccountBlocked.vue @@ -0,0 +1,130 @@ + + + diff --git a/packages/web-runtime/src/router/index.ts b/packages/web-runtime/src/router/index.ts index 46c66ef63c1..434ade55fc1 100644 --- a/packages/web-runtime/src/router/index.ts +++ b/packages/web-runtime/src/router/index.ts @@ -1,4 +1,5 @@ import AccessDeniedPage from '../pages/accessDenied.vue' +import LinkedAccountBlockedPage from '../pages/linkedAccountBlocked.vue' import Account from '../pages/account.vue' import LoginPage from '../pages/login.vue' import LogoutPage from '../pages/logout.vue' @@ -82,6 +83,12 @@ const routes = [ component: AccessDeniedPage, meta: { title: $gettext('Access denied'), authContext: 'anonymous' } }, + { + path: '/linked-account-blocked', + name: 'linkedAccountBlocked', + component: LinkedAccountBlockedPage, + meta: { title: $gettext('Account linking'), authContext: 'anonymous' } + }, { path: '/account', name: 'account', diff --git a/packages/web-runtime/src/router/setupAuthGuard.ts b/packages/web-runtime/src/router/setupAuthGuard.ts index f876542cebb..d971f657e3a 100644 --- a/packages/web-runtime/src/router/setupAuthGuard.ts +++ b/packages/web-runtime/src/router/setupAuthGuard.ts @@ -27,10 +27,11 @@ export const setupAuthGuard = (router: Router) => { await authService.initializeContext(to) // vue-router currently (4.1.6) does not cancel navigations when a new one is triggered - // we need to guard this case to be able to show the access denied page + // we need to guard this case to be able to show access denied or linked-account blocked // and not be redirected to the login page if (authService.hasAuthErrorOccurred) { - return to.name === 'accessDenied' || { name: 'accessDenied' } + const blockRoute = authService.authBlockRouteName + return to.name === blockRoute || { name: blockRoute } } if (isPublicLinkContextRequired(router, to)) { @@ -70,10 +71,11 @@ export const setupAuthGuard = (router: Router) => { return true }) router.afterEach((to) => { - if (to.name !== 'accessDenied') { + if (to.name !== 'accessDenied' && to.name !== 'linkedAccountBlocked') { return } authService.hasAuthErrorOccurred = false + authService.authBlockRouteName = 'accessDenied' }) } diff --git a/packages/web-runtime/src/services/auth/authService.ts b/packages/web-runtime/src/services/auth/authService.ts index 7d9ea9342a8..d3f958960b8 100644 --- a/packages/web-runtime/src/services/auth/authService.ts +++ b/packages/web-runtime/src/services/auth/authService.ts @@ -1,13 +1,17 @@ import { UserManager } from './userManager' import { PublicLinkManager } from './publicLinkManager' +import { Ability, PublicLinkType } from '@ownclouders/web-client' import { AuthStore, ClientService, - UserStore, + AuthServiceInterface, CapabilityStore, ConfigStore, + isLinkedPrimaryAccountError, useTokenTimerWorker, - AuthServiceInterface + UserStore, + wasLinkedPrimaryAuthHandled, + WebWorkersStore } from '@ownclouders/web-pkg' import { RouteLocation, Router } from 'vue-router' import { @@ -18,12 +22,11 @@ import { isUserContextRequired } from '../../router' import { unref } from 'vue' -import { Ability } from '@ownclouders/web-client' import { Language } from 'vue3-gettext' -import { PublicLinkType } from '@ownclouders/web-client' -import { WebWorkersStore } from '@ownclouders/web-pkg' import { isSilentRedirectRoute } from '../../helpers/silentRedirect' +export type AuthBlockRouteName = 'accessDenied' | 'linkedAccountBlocked' + export class AuthService implements AuthServiceInterface { private clientService: ClientService private configStore: ConfigStore @@ -44,6 +47,7 @@ export class AuthService implements AuthServiceInterface { private accessTokenExpiryThreshold = 10 public hasAuthErrorOccurred: boolean + public authBlockRouteName: AuthBlockRouteName = 'accessDenied' public initialize( configStore: ConfigStore, @@ -60,6 +64,7 @@ export class AuthService implements AuthServiceInterface { this.clientService = clientService this.router = router this.hasAuthErrorOccurred = false + this.authBlockRouteName = 'accessDenied' this.ability = ability this.language = language this.userStore = userStore @@ -166,7 +171,7 @@ export class AuthService implements AuthServiceInterface { await this.userManager.updateContext(user.access_token, fetchUserData) } catch (e) { console.error(e) - await this.handleAuthError(unref(this.router.currentRoute)) + await this.handleAuthError(unref(this.router.currentRoute), { cause: e }) } }) @@ -178,7 +183,7 @@ export class AuthService implements AuthServiceInterface { if (this.userManager.unloadReason === 'authError') { this.hasAuthErrorOccurred = true return this.router.push({ - name: 'accessDenied', + name: this.authBlockRouteName, query: { redirectUrl: unref(this.router.currentRoute)?.fullPath } }) } @@ -193,14 +198,14 @@ export class AuthService implements AuthServiceInterface { }) this.userManager.events.addSilentRenewError(async (error) => { console.error('Silent Renew Error:', error) - await this.handleAuthError(unref(this.router.currentRoute)) + await this.handleAuthError(unref(this.router.currentRoute), { cause: error }) }) this.userManager.areEventHandlersRegistered = true } // This is to prevent issues in embed mode when the expired token is still saved but already expired - // If the following code gets executed, it would toggle errorOccurred var which would then lead to redirect to the access denied screen + // If the following code runs it toggles errorOccurred and leads to accessDenied / linkedAccountBlocked if ( this.configStore.options.embed?.enabled && this.configStore.options.embed.delegateAuthentication @@ -228,7 +233,7 @@ export class AuthService implements AuthServiceInterface { } } catch (e) { console.error(e) - await this.handleAuthError(unref(this.router.currentRoute)) + await this.handleAuthError(unref(this.router.currentRoute), { cause: e }) } } } @@ -270,7 +275,7 @@ export class AuthService implements AuthServiceInterface { }) } catch (e) { console.warn('error during authentication:', e) - return this.handleAuthError(unref(this.router.currentRoute)) + return this.handleAuthError(unref(this.router.currentRoute), { cause: e }) } } @@ -295,8 +300,11 @@ export class AuthService implements AuthServiceInterface { public async handleAuthError( route: RouteLocation, - { forceLogout = false }: { forceLogout?: boolean } = {} + { forceLogout = false, cause }: { forceLogout?: boolean; cause?: unknown } = {} ) { + if (cause !== undefined && wasLinkedPrimaryAuthHandled(cause)) { + return + } if (isPublicLinkContextRequired(this.router, route)) { const token = extractPublicLinkToken(route) this.publicLinkManager.clear(token) @@ -319,16 +327,24 @@ export class AuthService implements AuthServiceInterface { return } + this.setAuthBlockRouteFromCause(cause) await this.userManager.removeUser('authError') this.tokenTimerWorker?.resetTokenTimer() return } - // authGuard is taking care of redirecting the user to the - // accessDenied page if hasAuthErrorOccurred is set to true - // we can't push the route ourselves, see authGuard for details. + // authGuard redirects via `hasAuthErrorOccurred` to `accessDenied` or `linkedAccountBlocked` + // when neither user nor IdP context can proceed; see setupAuthGuard. + this.setAuthBlockRouteFromCause(cause) this.hasAuthErrorOccurred = true } + private setAuthBlockRouteFromCause(cause: unknown) { + this.authBlockRouteName = + cause !== undefined && isLinkedPrimaryAccountError(cause) + ? 'linkedAccountBlocked' + : 'accessDenied' + } + public async resolvePublicLink( token: string, passwordRequired: boolean, diff --git a/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap b/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap new file mode 100644 index 00000000000..64767b554c2 --- /dev/null +++ b/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`linked account blocked page > renders component 1`] = ` +"
+ +
" +`; diff --git a/packages/web-runtime/tests/unit/pages/linkedAccountBlocked.spec.ts b/packages/web-runtime/tests/unit/pages/linkedAccountBlocked.spec.ts new file mode 100644 index 00000000000..38dec595370 --- /dev/null +++ b/packages/web-runtime/tests/unit/pages/linkedAccountBlocked.spec.ts @@ -0,0 +1,54 @@ +import linkedAccountBlocked from '../../../src/pages/linkedAccountBlocked.vue' +import { defaultComponentMocks, defaultPlugins, mount } from '@ownclouders/web-test-helpers' + +describe('linked account blocked page', () => { + it('renders component', () => { + const { wrapper } = getWrapper() + expect(wrapper.html()).toMatchSnapshot() + }) + + describe('"Log in again" button', () => { + it('navigates to "loginUrl" if set in config', () => { + const loginUrl = 'https://myidp.int/login' + const { wrapper } = getWrapper({ loginUrl }) + + const logInAgainButton = wrapper.find('#exitAnchor') + const loginAgainUrl = new URL(logInAgainButton.attributes().href) + loginAgainUrl.search = '' + + expect(logInAgainButton.exists()).toBeTruthy() + expect(loginAgainUrl.toString()).toEqual(loginUrl) + }) + }) +}) + +function getWrapper({ + loginUrl = '', + linkedAccount = undefined as { docUrl?: string; userPortalUrl?: string } | undefined +} = {}) { + const mocks = { + ...defaultComponentMocks() + } + + return { + mocks, + wrapper: mount(linkedAccountBlocked, { + global: { + plugins: [ + ...defaultPlugins({ + piniaOptions: { + configState: { + options: { + loginUrl, + ...(linkedAccount !== undefined && { linkedAccount }) + } + } + } + }) + ], + mocks, + provide: mocks + } + }) + } +} diff --git a/packages/web-runtime/tests/unit/services/auth/authService.spec.ts b/packages/web-runtime/tests/unit/services/auth/authService.spec.ts index 72c27502dad..aa75209037e 100644 --- a/packages/web-runtime/tests/unit/services/auth/authService.spec.ts +++ b/packages/web-runtime/tests/unit/services/auth/authService.spec.ts @@ -1,8 +1,15 @@ -import { ConfigStore, useAuthStore, useConfigStore } from '@ownclouders/web-pkg' +import { + ConfigStore, + markLinkedPrimaryAuthHandled, + useAuthStore, + useConfigStore +} from '@ownclouders/web-pkg' +import { AxiosError, InternalAxiosRequestConfig } from 'axios' import { mock } from 'vitest-mock-extended' import { Router } from 'vue-router' import { AuthService } from '../../../../src/services/auth/authService' import { UserManager } from '../../../../src/services/auth/userManager' +import * as routerHelpers from '../../../../src/router' import { RouteLocation, createRouter, createTestingPinia } from '@ownclouders/web-test-helpers' const mockUpdateContext = vi.fn() @@ -10,6 +17,26 @@ console.debug = vi.fn() vi.mock('../../../../src/services/auth/userManager') +function linkedPrimaryCause(): AxiosError { + return new AxiosError( + 'conflict', + 'ERR_BAD_REQUEST', + { + url: '/graph/v1.0/me', + baseURL: 'https://example.com/', + headers: {} + } as InternalAxiosRequestConfig, + {}, + { + status: 409, + statusText: 'Conflict', + data: { error: { code: 'linkedPrimaryAccount', message: 'x' } }, + headers: {}, + config: {} as AxiosError['config'] + } + ) +} + const initAuthService = ({ authService, configStore = null, @@ -153,4 +180,81 @@ describe('AuthService', () => { expect(mockUpdateContext).toHaveBeenCalledWith('access-token', true) }) }) + + describe('handleAuthError', () => { + describe('linked-primary cause routing', () => { + beforeEach(() => { + vi.spyOn(routerHelpers, 'isPublicLinkContextRequired').mockReturnValue(false) + vi.spyOn(routerHelpers, 'isUserContextRequired').mockReturnValue(true) + vi.spyOn(routerHelpers, 'isIdpContextRequired').mockReturnValue(false) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sets authBlockRouteName to linkedAccountBlocked when cause matches linked primary', async () => { + const authService = new AuthService() + const removeUser = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(authService, 'userManager', { + value: mock({ + getUser: vi.fn().mockResolvedValue({ expires_in: 3600 }), + removeUser + }) + }) + + const router = createRouter() + initAuthService({ authService, router }) + + await authService.handleAuthError(mock({}), { + cause: linkedPrimaryCause() + }) + + expect(authService.authBlockRouteName).toBe('linkedAccountBlocked') + expect(removeUser).toHaveBeenCalledWith('authError') + }) + + it('sets authBlockRouteName to accessDenied for generic errors', async () => { + const authService = new AuthService() + const removeUser = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(authService, 'userManager', { + value: mock({ + getUser: vi.fn().mockResolvedValue({ expires_in: 3600 }), + removeUser + }) + }) + + const router = createRouter() + initAuthService({ authService, router }) + + await authService.handleAuthError(mock({}), { + cause: new Error('generic') + }) + + expect(authService.authBlockRouteName).toBe('accessDenied') + expect(removeUser).toHaveBeenCalledWith('authError') + }) + + it('does not call removeUser twice when cause already marked handled by interceptor', async () => { + const authService = new AuthService() + const removeUser = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(authService, 'userManager', { + value: mock({ + getUser: vi.fn().mockResolvedValue({ expires_in: 3600 }), + removeUser + }) + }) + + const router = createRouter() + initAuthService({ authService, router }) + + const cause = linkedPrimaryCause() + await authService.handleAuthError(mock({}), { cause }) + markLinkedPrimaryAuthHandled(cause) + await authService.handleAuthError(mock({}), { cause }) + + expect(removeUser).toHaveBeenCalledTimes(1) + }) + }) + }) }) From 655ad8ab733133f35b498c35b1e513768b446db6 Mon Sep 17 00:00:00 2001 From: Gerard Mc Ardle Date: Wed, 3 Jun 2026 11:20:08 +0200 Subject: [PATCH 2/5] Fix linked account handler return type --- packages/web-runtime/src/container/bootstrap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 3cb92661ef9..e0a0522b943 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -567,7 +567,7 @@ export const announceAuthService = ({ webWorkersStore ) ;(clientService as ClientService).attachLinkedPrimaryAccountHandling((err) => - authService.handleAuthError(unref(router.currentRoute), { cause: err }) + authService.handleAuthError(unref(router.currentRoute), { cause: err }).then(() => undefined) ) app.config.globalProperties.$authService = authService From a63a72cf9fbc454b3b2b7759bea14a66139b270d Mon Sep 17 00:00:00 2001 From: Gerard Mc Ardle Date: Wed, 3 Jun 2026 12:42:22 +0200 Subject: [PATCH 3/5] Refactor linked primary account error handling and enhance linked account blocked page - Updated linked primary account error detection to include trusted signals for protected endpoints. - Improved error handling logic in `isLinkedPrimaryAccountError` function. - Added default documentation and portal URLs in the linked account blocked page component. - Enhanced unit tests to cover new scenarios for linked primary account errors and default link rendering. --- .gitignore | 3 ++ .../helpers/auth/linkedPrimaryAccountError.ts | 26 ++++------- .../auth/linkedPrimaryAccountError.spec.ts | 36 +++++++++++++-- .../src/pages/linkedAccountBlocked.vue | 12 ++++- .../linkedAccountBlocked.spec.ts.snap | 8 +++- .../unit/pages/linkedAccountBlocked.spec.ts | 45 +++++++++++++++++++ 6 files changed, 107 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index a352619e1e1..74e7fae903f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,9 @@ tests/report/cucumber_report.html # third party licenses /third-party-licenses +# Local files (keep in repo root on any branch; do not commit) +LOCAL_* + # dev setup /dev/docker/ocis-ca /dev/docker/traefik/certificates diff --git a/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts b/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts index 014df37d046..a667e285237 100644 --- a/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts +++ b/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts @@ -1,16 +1,8 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios' -/** - * LOCAL 4348: Linked primary account detection on identity bootstrap routes only for matching HTTP responses. - * - * Post-bootstrap behaviour relies on Axios response interceptors wired from runtime (`ClientService.attachLinkedPrimaryAccountHandling`) - * on Graph, OCS, and authenticated HTTP stacks only. WebDAV and other non-Axios transports are out of scope unless backends expose the same Axios-shaped errors there. - */ - /** * Machine-readable codes backends may return for "linked primary account" - * on identity bootstrap routes (Graph `/me`, settings, OCS user/capabilities). - * Align error payloads with your deployment; do not treat arbitrary 409 as this case. + * conflicts. These are trusted enough to use on authenticated protected endpoints. */ const LINKED_PRIMARY_ERROR_CODES = new Set([ 'linkedPrimaryAccount', @@ -112,14 +104,17 @@ export function markLinkedPrimaryAuthHandled(error: unknown): void { if (!axios.isAxiosError(error) || !error.config) { return } - ;(error.config as unknown as Record)[LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY] = true + ;(error.config as unknown as Record)[LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY] = + true } export function wasLinkedPrimaryAuthHandled(error: unknown): boolean { if (!axios.isAxiosError(error) || !error.config) { return false } - return !!(error.config as unknown as Record)[LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY] + return !!(error.config as unknown as Record)[ + LINKED_PRIMARY_AUTH_HANDLED_CONFIG_KEY + ] } /** @@ -142,8 +137,8 @@ export function attachLinkedPrimaryAccountResponseInterceptor( } /** - * True when the response matches the linked-primary contract on an identity - * bootstrap URL (so unrelated WebDAV or editor 409s are excluded). + * True when a protected Axios response matches the linked-primary contract. + * Header and code are trusted signals; message matching stays limited to bootstrap URLs. */ export function isLinkedPrimaryAccountError(err: unknown): boolean { if (!axios.isAxiosError(err)) { @@ -154,9 +149,6 @@ export function isLinkedPrimaryAccountError(err: unknown): boolean { return false } const path = resolveRequestPath(ae.config) - if (!isIdentityBootstrapRequestUrl(path)) { - return false - } if (readLinkedPrimaryHeader(ae.response.headers)) { return true } @@ -164,5 +156,5 @@ export function isLinkedPrimaryAccountError(err: unknown): boolean { if (code && LINKED_PRIMARY_ERROR_CODES.has(code)) { return true } - return messageSuggestsLinkedPrimary(ae.response.data) + return isIdentityBootstrapRequestUrl(path) && messageSuggestsLinkedPrimary(ae.response.data) } diff --git a/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts b/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts index 613016d1bfd..7da8567017a 100644 --- a/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts +++ b/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts @@ -37,10 +37,10 @@ describe('isLinkedPrimaryAccountError', () => { expect(isLinkedPrimaryAccountError(new Error('oops'))).toBe(false) }) - it('returns false for 409 on non-bootstrap URLs', () => { + it('returns false for 409 without linked-primary signal', () => { const error = err409({ url: '/remote.php/dav/files/foo', - data: { error: { code: 'linkedPrimaryAccount' } } + data: { error: { code: 'resourceAlreadyExists' } } }) expect(isLinkedPrimaryAccountError(error)).toBe(false) }) @@ -69,6 +69,23 @@ describe('isLinkedPrimaryAccountError', () => { expect(isLinkedPrimaryAccountError(error)).toBe(true) }) + it('returns true for trusted linked-primary header on protected endpoint URLs', () => { + const error = err409({ + url: '/api/v0/storage/spaces', + data: {}, + headers: { 'x-oc-linked-primary-account': 'true' } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + + it('returns true for linked-primary code on protected endpoint URLs', () => { + const error = err409({ + url: '/api/v0/storage/spaces', + data: { error: { code: 'linkedPrimaryAccount' } } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(true) + }) + it('returns true for settings API path with matching code', () => { const error = err409({ url: '/api/v0/settings/roles-list', @@ -112,6 +129,19 @@ describe('isLinkedPrimaryAccountError', () => { }) expect(isLinkedPrimaryAccountError(error)).toBe(true) }) + + it('returns false when only error.message suggests linked primary on non-bootstrap URLs', () => { + const error = err409({ + url: '/api/v0/files/some-operation', + data: { + error: { + code: 'unknown', + message: 'Your linked primary account cannot access this application' + } + } + }) + expect(isLinkedPrimaryAccountError(error)).toBe(false) + }) }) describe('linkedPrimaryAuthHandled marker', () => { @@ -151,7 +181,7 @@ describe('attachLinkedPrimaryAccountResponseInterceptor', () => { const onRejected = useSpy.mock.calls[0][1] as (err: unknown) => Promise const rejection = err409({ url: '/remote.php/dav/files/foo', - data: { error: { code: 'linkedPrimaryAccount' } } + data: { error: { code: 'resourceAlreadyExists' } } }) await expect(onRejected(rejection)).rejects.toBe(rejection) diff --git a/packages/web-runtime/src/pages/linkedAccountBlocked.vue b/packages/web-runtime/src/pages/linkedAccountBlocked.vue index f8e3e48eeca..89135509c91 100644 --- a/packages/web-runtime/src/pages/linkedAccountBlocked.vue +++ b/packages/web-runtime/src/pages/linkedAccountBlocked.vue @@ -8,6 +8,7 @@
currentTheme.value.common.slogan) const logoImg = computed(() => currentTheme.value.logo.login) - const linkedAccountDocUrl = computed(() => configStore.options.linkedAccount?.docUrl || '') + const linkedAccountDocUrl = computed( + () => configStore.options.linkedAccount?.docUrl || DEFAULT_LINKED_ACCOUNT_DOC_URL + ) const linkedAccountPortalUrl = computed(() => { const explicit = configStore.options.linkedAccount?.userPortalUrl if (explicit) { return explicit } - return configStore.options.accountEditLink?.href || '' + return configStore.options.accountEditLink?.href || DEFAULT_LINKED_ACCOUNT_PORTAL_URL }) const cardTitle = computed(() => { diff --git a/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap b/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap index 64767b554c2..fbfdc50f654 100644 --- a/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap +++ b/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap @@ -6,7 +6,13 @@ exports[`linked account blocked page > renders component 1`] = `