Skip to content
Draft
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
11 changes: 11 additions & 0 deletions changelog/unreleased/enhancement-ocm-wayf-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Enhancement: Add WAYF page for OCM

We've implemented a Where Are You From page that allows users to discover and select their cloud provider when accepting federated invitations.
The new page includes federation browsing, provider search functionality, and manual provider entry with automatic OCM discovery.

The implementation added new routes (`/wayf` and `/accept-invite`) with anonymous authentication support, enabling users to access the provider selection interface without prior authentication.
Outgoing invitations now include multiple token copy options (plain, base64, and WAYF link) for improved sharing flexibility.

Enhancements prevent users from accepting self-generated invitations and automatically exclude the current instance from available provider lists.

https://github.com/owncloud/web/pull/13243
122 changes: 122 additions & 0 deletions packages/web-app-ocm/docs/Where Are You From page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Where Are You From

## Overview

The WAYF page allows users to select their cloud provider when accepting an invitation. It's accessible at the path `domain.tld/open-cloud-mesh/wayf?token=token`.

## Usage

### 1. Basic Functionality Test

Navigate to: `http://localhost:3000/open-cloud-mesh/wayf?token=test-token-123`

Expected behavior:

- Page loads with "Where Are You From?" header
- Shows loading state initially
- Displays list of available providers grouped by federation
- Search functionality works
- Manual provider input is available

### 2. URL Parameters Test

Test with different URL parameters:

- `?token=test-token-123` - Should show providers
- No token - Should show "You need a token for this feature to work"

**Note**: `providerDomain` is ALWAYS auto-detected from `window.location.hostname` and should NOT be passed in the URL for security reasons.

### 3. Provider Selection Test

- Click on any provider from the list
- Should redirect to the provider's accept-invite page with token and domain parameters

### 4. Manual Provider Test

- Enter a domain in the manual provider field (e.g., "example.com")
- Press Enter or click the input
- Should attempt to discover the provider and redirect

### 5. Search Functionality Test

- Type in the search box to filter providers
- Should filter by provider name or FQDN
- Clear search should show all providers again

### 6. Error Handling Test

- Test with invalid provider domains
- Should show appropriate error messages
- Should not crash the application

### 7. Self-Domain Prevention Test

**Test that users cannot select their own instance:**

- If `window.location.hostname` is `example.com`:
- Federation list should NOT include any provider with FQDN `example.com`
- Manual entry of `example.com` should show error
- Manual entry of `http://example.com` should show error
- Manual entry of `https://example.com:443` should show error
- Error message should be: "Invalid Provider Selection - You cannot select your own instance as a provider..."

### How It Works

1. **Federation List Filtering**: When loading federations from the backend, any provider whose domain matches `window.location.hostname` is automatically filtered out
2. **Provider Selection Validation**: If a user somehow selects their own domain from the list, an error message is shown
3. **Manual Entry Validation**: If a user manually enters their own domain, an error message is shown

### Error Message

When attempting to select own instance:

```
Title: Invalid Provider Selection
Message: You cannot select your own instance as a provider.
Please select a different provider to establish a federated connection.
```

### Implementation Details

- Domain comparison is case-insensitive
- Protocols (`http://`, `https://`) are stripped before comparison
- Port numbers are stripped before comparison
- If backend returns the current instance in federations, it's automatically dropped from the list

## API Endpoints Used

### Backend Endpoints

- `GET /sciencemesh/federations` - Loads list of available federations (public endpoint)
- `POST /sciencemesh/discover` - Discovers provider's OCM API endpoint for manual entry (public endpoint)

### External OCM Discovery

- `https://{provider-domain}/.well-known/ocm` - OCM discovery endpoint (called by backend, not frontend)
- `https://{provider-domain}/ocm-provider` - Legacy OCM discovery endpoint (fallback)

## Expected URL Structure

When a provider is selected, the user is redirected to:

```
{provider-invite-accept-dialog}?token={token}&providerDomain={providerDomain}
```

**Important Clarification**:

- `providerDomain` = The domain **where the WAYF page is hosted** (YOUR domain, the inviting party)
- `provider-invite-accept-dialog` = The selected provider's invite acceptance URL

**Example Flow**:

1. User visits WAYF on: `your-domain.com/open-cloud-mesh/wayf?token=abc123`
2. User selects provider: CERNBox
3. User is redirected to: `qa.cernbox.cern.ch/accept?token=abc123&providerDomain=your-domain.com`
- Note: `providerDomain` is `your-domain.com` (NOT `qa.cernbox.cern.ch`)

**How providerDomain is determined**:

- **ALWAYS** automatically extracted from `window.location.hostname`
- **NEVER** accepted from query string (security: prevents domain spoofing)
66 changes: 66 additions & 0 deletions packages/web-app-ocm/src/composables/useInvitationAcceptance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ref, computed } from 'vue'
import { useClientService, useMessages, useInviteTokensListStore } from '@ownclouders/web-pkg'
import { useGettext } from 'vue3-gettext'

export function useInvitationAcceptance() {
const clientService = useClientService()
const { showErrorMessage } = useMessages()
const inviteTokensListStore = useInviteTokensListStore()
const { $gettext } = useGettext()

const loading = ref(false)
const error = ref(false)

const isOwnGeneratedToken = (token: string) => {
return inviteTokensListStore.getTokensList().some((t) => t.token === token)
}

const errorPopup = (error: Error) => {
console.error(error)
showErrorMessage({
title: $gettext('Error'),
desc: $gettext('An error occurred'),
errors: [error]
})
}

const acceptInvitation = async (token: string, providerDomain: string) => {
loading.value = true
error.value = false

try {
if (isOwnGeneratedToken(token)) {
throw new Error($gettext('Self-invitations are not permitted'))
}

const response = await clientService.httpAuthenticated.post('/sciencemesh/accept-invite', {
token,
providerDomain
})

return true
} catch (err) {
console.error('Error accepting invitation:', err)
error.value = true
errorPopup(err)
throw err
} finally {
loading.value = false
}
}

const validateParameters = (token: string | undefined, providerDomain: string | undefined) => {
if (!token || !providerDomain) {
throw new Error($gettext('Missing required parameters: token and providerDomain'))
}
}

return {
loading: computed(() => loading.value),
error: computed(() => error.value),
isOwnGeneratedToken,
acceptInvitation,
validateParameters,
errorPopup
}
}
195 changes: 195 additions & 0 deletions packages/web-app-ocm/src/composables/useWayf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { ref, computed } from 'vue'
import { useClientService, useMessages } from '@ownclouders/web-pkg'
import { useGettext } from 'vue3-gettext'
import type {
WayfProvider,
WayfFederation,
FederationsApiResponse,
DiscoverRequest,
DiscoverResponse
} from '../types/wayf'

export function useWayf() {
const clientService = useClientService()
const { showErrorMessage } = useMessages()
const { $gettext } = useGettext()

const loading = ref(false)
const error = ref(false)
const federations = ref<WayfFederation>({})

const isSelfDomain = (domain: string): boolean => {
const currentHost = window.location.hostname.toLowerCase()
const checkDomain = domain
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/:\d+$/, '')
return currentHost === checkDomain
}

const discoverProvider = async (domain: string): Promise<string> => {
try {
loading.value = true
const response = await clientService.httpUnAuthenticated.post<DiscoverResponse>(
'/sciencemesh/discover',
{ domain } as DiscoverRequest
)

if (!response.data || !response.data.inviteAcceptDialog) {
throw new Error('No invite accept dialog found in discovery response')
}

return response.data.inviteAcceptDialog
} catch (err) {
console.error('Provider discovery failed:', err)
showErrorMessage({
title: $gettext('Discovery Failed'),
desc: $gettext('Could not discover provider at %{domain}', { domain }),
errors: [err]
})
throw err
} finally {
loading.value = false
}
}

const buildProviderUrl = (baseUrl: string, token: string, providerDomain?: string): string => {
const url = new URL(baseUrl)
if (providerDomain) url.searchParams.set('providerDomain', providerDomain)
if (token) url.searchParams.set('token', token)
return url.toString()
}

const navigateToProvider = async (
provider: WayfProvider,
token: string,
providerDomain?: string
) => {
if (isSelfDomain(provider.fqdn)) {
showErrorMessage({
title: $gettext('Invalid Provider Selection'),
desc: $gettext(
'You cannot select your own instance as a provider. Please select a different provider to establish a federated connection.'
)
})
return
}

try {
loading.value = true
let inviteDialogUrl = provider.inviteAcceptDialog

// If inviteAcceptDialog is empty, call backend discovery
if (!inviteDialogUrl || inviteDialogUrl.trim() === '') {
inviteDialogUrl = await discoverProvider(provider.fqdn)
} else {
// If it's a relative path, make it absolute
if (inviteDialogUrl.startsWith('/')) {
const baseUrl = `https://${provider.fqdn}`
inviteDialogUrl = `${baseUrl}${inviteDialogUrl}`
}
// If it's already absolute, use as-is
}

// Build final URL with query parameters and redirect
const finalUrl = buildProviderUrl(inviteDialogUrl, token, providerDomain)
window.location.href = finalUrl
} catch (err) {
console.error('Failed to navigate to provider:', err)
// Error is already shown by discoverProvider, do not use showErrorMessage here
} finally {
loading.value = false
}
}

const navigateToManualProvider = async (
input: string,
token: string,
providerDomain?: string
) => {
const trimmedInput = input.trim()
if (!trimmedInput) return

if (isSelfDomain(trimmedInput)) {
showErrorMessage({
title: $gettext('Invalid Provider Selection'),
desc: $gettext(
'You cannot use your own instance as a provider. Please select a different provider to establish a federated connection.'
)
})
return
}

try {
loading.value = true
const inviteDialogUrl = await discoverProvider(trimmedInput)
const finalUrl = buildProviderUrl(inviteDialogUrl, token, providerDomain)
window.location.href = finalUrl
} catch (err) {
console.error('Failed to navigate to manual provider:', err)
// Error is already shown by discoverProvider, do not use showErrorMessage here
} finally {
loading.value = false
}
}

const loadFederations = async () => {
try {
loading.value = true
error.value = false

const response = await clientService.httpUnAuthenticated.get<FederationsApiResponse>(
'/sciencemesh/federations'
)

const transformedFederations: WayfFederation = {}
response.data.forEach((fed) => {
const providers = fed.servers
.map((server) => ({
name: server.displayName,
fqdn: new URL(server.url).hostname,
// Keep empty if not provided by the server
inviteAcceptDialog: server.inviteAcceptDialog || ''
}))
.filter((provider) => !isSelfDomain(provider.fqdn))

if (providers.length > 0) {
transformedFederations[fed.federation] = providers
}
})

federations.value = transformedFederations
} catch (err) {
console.error('Failed to load federations:', err)
error.value = true
showErrorMessage({
title: $gettext('Failed to Load Providers'),
desc: $gettext('Could not load the list of available providers'),
errors: [err]
})
} finally {
loading.value = false
}
}

const filterProviders = (providers: WayfProvider[], query: string): WayfProvider[] => {
const searchTerm = (query || '').toLowerCase()
return providers.filter(
(provider) =>
provider.name.toLowerCase().includes(searchTerm) ||
provider.fqdn.toLowerCase().includes(searchTerm)
)
}

return {
loading: computed(() => loading.value),
error: computed(() => error.value),
federations: computed(() => federations.value),
discoverProvider,
buildProviderUrl,
navigateToProvider,
navigateToManualProvider,
loadFederations,
filterProviders
}
}
Loading