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/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..edc7ff7dbfa --- /dev/null +++ b/packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts @@ -0,0 +1,176 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios' + +/** + * Detects Reva's linked-primary sign-in contract: HTTP 409 with header + * `X-Oc-Linked-Primary-Account: true` and/or `error.code` `linkedPrimaryAccount` + * (see Reva `identity-auth-http-errors` documentation). + */ + +/** + * Machine-readable codes backends may return for "linked primary account" + * conflicts. These are trusted enough to use on authenticated protected endpoints. + */ +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 + ] +} + +/** + * Axios response error handler: runs `onDetected` for linked-primary 409s and marks the + * error so duplicate `handleAuthError(..., { cause })` calls no-op. + */ +export function createLinkedPrimaryRejectionHandler( + onDetected: (error: unknown) => void | Promise +): (error: unknown) => Promise { + return async (error: unknown) => { + if (isLinkedPrimaryAccountError(error)) { + await onDetected(error) + markLinkedPrimaryAuthHandled(error) + } + return Promise.reject(error) + } +} + +/** + * Registers an Axios response interceptor for linked-primary 409 responses. + */ +export function attachLinkedPrimaryAccountResponseInterceptor( + axiosInstance: AxiosInstance, + onDetected: (error: unknown) => void | Promise +): number { + return axiosInstance.interceptors.response.use( + (response) => response, + createLinkedPrimaryRejectionHandler(onDetected) + ) +} + +/** + * 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)) { + return false + } + const ae = err as AxiosError + if (ae.response?.status !== 409) { + return false + } + const path = resolveRequestPath(ae.config) + if (readLinkedPrimaryHeader(ae.response.headers)) { + return true + } + const code = readGraphStyleErrorCode(ae.response.data) + if (code && LINKED_PRIMARY_ERROR_CODES.has(code)) { + return true + } + return isIdentityBootstrapRequestUrl(path) && 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..100c3a6b4dd 100644 --- a/packages/web-pkg/src/services/client/client.ts +++ b/packages/web-pkg/src/services/client/client.ts @@ -10,6 +10,11 @@ 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, + createLinkedPrimaryRejectionHandler +} from '../../helpers/auth/linkedPrimaryAccountError' const createFetchOptions = (authParams: AuthParameters, language: string): FetchEventSourceInit => { return { @@ -39,6 +44,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 +117,37 @@ export class ClientService { return this.webDavClient } + /** + * Registers linked-primary 409 handling on Graph, OCS, and authenticated HTTP clients. + * 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( + createLinkedPrimaryRejectionHandler(handler) + ) + } + 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 +157,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..787be856439 --- /dev/null +++ b/packages/web-pkg/tests/unit/helpers/auth/linkedPrimaryAccountError.spec.ts @@ -0,0 +1,205 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { describe, expect, it, vi } from 'vitest' +import { + attachLinkedPrimaryAccountResponseInterceptor, + createLinkedPrimaryRejectionHandler, + 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 without linked-primary signal', () => { + const error = err409({ + url: '/remote.php/dav/files/foo', + data: { error: { code: 'resourceAlreadyExists' } } + }) + 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 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', + 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) + }) + + 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', () => { + it('markLinkedPrimaryAuthHandled sets flag readable by wasLinkedPrimaryAuthHandled', () => { + const error = err409({}) + expect(wasLinkedPrimaryAuthHandled(error)).toBe(false) + markLinkedPrimaryAuthHandled(error) + expect(wasLinkedPrimaryAuthHandled(error)).toBe(true) + }) +}) + +describe('createLinkedPrimaryRejectionHandler', () => { + it('invokes onDetected and marks handled for linked-primary errors', async () => { + const handler = vi.fn().mockResolvedValue(undefined) + const onRejected = createLinkedPrimaryRejectionHandler(handler) + const rejection = err409({}) + + await expect(onRejected(rejection)).rejects.toBe(rejection) + + expect(handler).toHaveBeenCalledTimes(1) + expect(wasLinkedPrimaryAuthHandled(rejection)).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: 'resourceAlreadyExists' } } + }) + + 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..e0a0522b943 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 }).then(() => undefined) + ) + 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..f6790a027be --- /dev/null +++ b/packages/web-runtime/src/pages/linkedAccountBlocked.vue @@ -0,0 +1,115 @@ + + + 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..616d9e6d4a1 --- /dev/null +++ b/packages/web-runtime/tests/unit/pages/__snapshots__/linkedAccountBlocked.spec.ts.snap @@ -0,0 +1,25 @@ +// 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..68799d5e1e8 --- /dev/null +++ b/packages/web-runtime/tests/unit/pages/linkedAccountBlocked.spec.ts @@ -0,0 +1,98 @@ +import linkedAccountBlocked from '../../../src/pages/linkedAccountBlocked.vue' +import { authService } from '../../../src/services/auth' +import { defaultComponentMocks, defaultPlugins, mount } from '@ownclouders/web-test-helpers' +import { vi } from 'vitest' + +describe('linked account blocked page', () => { + it('renders component', () => { + const { wrapper } = getWrapper() + expect(wrapper.html()).toMatchSnapshot() + }) + + it('renders default documentation and unlink portal links', () => { + const { wrapper } = getWrapper() + + expect(wrapper.find('[data-testid="linked-account-doc-link"]').attributes('href')).toBe( + 'https://auth.docs.cern.ch/user-documentation/verified-guest/' + ) + expect(wrapper.find('[data-testid="linked-account-portal-link"]').attributes('href')).toBe( + 'https://account.cern.ch/account/' + ) + }) + + it('renders configured documentation and unlink portal links', () => { + const { wrapper } = getWrapper({ + linkedAccount: { + docUrl: 'https://docs.example.test/linked-account', + userPortalUrl: 'https://portal.example.test/linked-accounts' + } + }) + + const docLink = wrapper.find('[data-testid="linked-account-doc-link"]') + const portalLink = wrapper.find('[data-testid="linked-account-portal-link"]') + + expect(docLink.attributes('href')).toBe('https://docs.example.test/linked-account') + expect(docLink.attributes('target')).toBe('_blank') + expect(docLink.attributes('rel')).toBe('noopener noreferrer') + expect(portalLink.attributes('href')).toBe('https://portal.example.test/linked-accounts') + expect(portalLink.attributes('target')).toBe('_blank') + expect(portalLink.attributes('rel')).toBe('noopener noreferrer') + }) + + it('uses accountEditLink as unlink portal fallback', () => { + const { wrapper } = getWrapper({ + accountEditLink: { href: 'https://account.example.test/edit' }, + linkedAccount: { + docUrl: 'https://docs.example.test/linked-account' + } + }) + + expect(wrapper.find('[data-testid="linked-account-portal-link"]').attributes('href')).toBe( + 'https://account.example.test/edit' + ) + }) + + describe('"Log out" button', () => { + it('calls authService.logoutUser when clicked', async () => { + const logoutUserSpy = vi.spyOn(authService, 'logoutUser').mockResolvedValue(undefined) + const { wrapper } = getWrapper() + + await wrapper.find('#exitAnchor').trigger('click') + + expect(logoutUserSpy).toHaveBeenCalledTimes(1) + }) + }) +}) + +function getWrapper({ + loginUrl = '', + accountEditLink = undefined as { href?: string } | undefined, + linkedAccount = undefined as { docUrl?: string; userPortalUrl?: string } | undefined +} = {}) { + const mocks = { + ...defaultComponentMocks() + } + + return { + mocks, + wrapper: mount(linkedAccountBlocked, { + global: { + plugins: [ + ...defaultPlugins({ + piniaOptions: { + configState: { + options: { + loginUrl, + ...(accountEditLink !== undefined && { accountEditLink }), + ...(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) + }) + }) + }) })