Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>
logoutUser(): Promise<void | NavigationFailure>
getRefreshToken(): Promise<string>
Expand Down
8 changes: 7 additions & 1 deletion packages/web-pkg/src/composables/piniaStores/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof OptionsConfigSchema>
Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/helpers/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './linkedPrimaryAccountError'
176 changes: 176 additions & 0 deletions packages/web-pkg/src/helpers/auth/linkedPrimaryAccountError.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<string, string | undefined>
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<string, unknown>
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<string, unknown>)[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<string, unknown>)[
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<void>
): (error: unknown) => Promise<unknown> {
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<void>
): 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)
}
1 change: 1 addition & 0 deletions packages/web-pkg/src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './auth'
export * from './cache'
export * from './folderLink'
export * from './resource'
Expand Down
6 changes: 6 additions & 0 deletions packages/web-pkg/src/http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export class HttpClient {
this.cancelToken.cancel(msg)
}

public useResponseErrorInterceptor(
onRejected: (error: unknown) => unknown | Promise<unknown>
): number {
return this.instance.interceptors.response.use(undefined, onRejected)
}

public async delete<T = any, D = any, S extends z.Schema | T = T>(
url: string,
data?: D,
Expand Down
34 changes: 34 additions & 0 deletions packages/web-pkg/src/services/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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>
): 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
Expand All @@ -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
Expand Down
Loading