Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: delegate auth in embed mode #10082

Merged
merged 1 commit into from
Nov 29, 2023
Merged
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
7 changes: 7 additions & 0 deletions changelog/unreleased/enhancement-add-auth-delegation
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Enhancement: Add authentication delegation in the Embed mode

We've added authentication delegation so that the user does not need to reauthenticate when the parent application already holds a
valid access token for the user.

https://github.com/owncloud/web/pull/10082
https://github.com/owncloud/web/issues/10072
28 changes: 25 additions & 3 deletions docs/embed-mode/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The ownCloud Web can be consumed by another application in a stripped down versi

## Getting started

To integrate ownCloud Web into your application, add an iframe element pointing to your ownCloud Web deployed instance with additional query parameter `mode=embed`.
To integrate ownCloud Web into your application, add an iframe element pointing to your ownCloud Web deployed instance with additional query parameter `embed=true`.

```html
<iframe src="<web-url>?mode=embed"></iframe>
Expand All @@ -41,7 +41,7 @@ To maintain uniformity and ease of handling, each event encapsulates the same st
### Example

```html
<iframe src="https://my-owncloud-web-instance?mode=embed"></iframe>
<iframe src="https://my-owncloud-web-instance?embed=true"></iframe>

<script>
function selectEventHandler(event) {
Expand All @@ -65,7 +65,7 @@ By default, the Embed mode allows users to select resources. In certain cases (e
### Example

```html
<iframe src="https://my-owncloud-web-instance?mode=embed&embed-target=location"></iframe>
<iframe src="https://my-owncloud-web-instance?embed=true&embed-target=location"></iframe>

<script>
function selectEventHandler(event) {
Expand All @@ -81,3 +81,25 @@ By default, the Embed mode allows users to select resources. In certain cases (e
window.addEventListener('message', selectEventHandler)
</script>
```

## Delegate authentication

If you already have a valid `access_token` that can be used to call the API from within the Embed mode and do not want to force the user to authenticate again, you can delegate the authentication. Delegating authentication will disable internal login form in ownCloud Web and will instead use events to obtain the token and update it.

### Configuration

To allow authentication delegation, you need to set the config option `options.embed.delegateAuthentication` to `true`. This can be achieved via query parameter `embed-delegate-authentication=true`. Because we are using the `postMessage` method to communicate across different origins, it is best practice to verify that the event originated from a known origin and not from some malicious site. We highly recommend to allow this check in production environments. You can enable it by setting the config option `options.embed.delegateAuthenticationOrigin` via query parameter `embed-delegate-authentication-origin=my-origin`. The value of this parameter will be compared against the `MessageEvent.origin` value and if they do not match, the token will be rejected.

### Events

#### Opening Embed mode

As already mentioned, we're using the `postMessage` method to allow communication between the Embed mode and the parent application. When the Embed mode is opened for the first time, the user gets redirected to the `/web-oidc-callback` page where a message with payload `{ name: 'owncloud-embed:request-token', data: undefined }` is sent to request the `access_token` from the parent application. The parent application should set an event listener before opening the Embed mode and once received, it should send a message with payload `{ name: 'owncloud-embed:update-token', data: { access_token: '<bearer-token>' } }`. Once the Embed mode receives this message, it will save the token in the application state and will automatically authenticate the user.

{{< hint info >}}
To save unnecessary duplication of messages with only different names, the name in the message payload above is exactly the same for both the initial authentication and subsequent token updates after renewal.
{{< /hint >}}

#### Updating the token

When authentication is delegated, the automatic renewal of the token inside of ownCloud Web is disabled. In order to update the token, a listener is created which awaits a message with payload `{ name: 'owncloud-embed:update-token', data: { access_token: '<bearer-token>' } }`. The token will then be replaced inside of the Embed mode automatically.
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ import {
import EmbedActions from 'web-app-files/src/components/EmbedActions/EmbedActions.vue'
import { getDefaultLinkPermissions } from '@ownclouders/web-pkg'
import { SharePermissionBit } from '@ownclouders/web-client/src/helpers'
import { computed } from 'vue'

const mockPostMessage = jest.fn()
const mockUseEmbedMode = jest
.fn()
.mockReturnValue({ isLocationPicker: computed(() => false), postMessage: mockPostMessage })

jest.mock('@ownclouders/web-pkg', () => ({
...jest.requireActual('@ownclouders/web-pkg'),
createQuicklink: jest.fn().mockImplementation(({ resource, password }) => ({
url: (password ? password + '-' : '') + 'link-' + resource.id
})),
showQuickLinkPasswordModal: jest.fn().mockImplementation((_options, cb) => cb('password')),
getDefaultLinkPermissions: jest.fn()
getDefaultLinkPermissions: jest.fn(),
useEmbedMode: jest.fn().mockImplementation(() => mockUseEmbedMode())
}))

const selectors = Object.freeze({
Expand Down Expand Up @@ -42,124 +49,79 @@ describe('EmbedActions', () => {
})

it('should emit select event when the select action is triggered', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

await wrapper.find(selectors.btnSelect).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:select',
data: [{ id: 1 }]
},
{}
)
expect(mockPostMessage).toHaveBeenCalledWith('owncloud-embed:select', [{ id: 1 }])
})

it('should enable select action when embedTarget is set to location', () => {
const { wrapper } = getWrapper({
configuration: { options: { embed: { target: 'location' } } }
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => true),
postMessage: mockPostMessage
})

const { wrapper } = getWrapper()

expect(wrapper.find(selectors.btnSelect).attributes()).not.toHaveProperty('disabled')
})

it('should emit select event with currentFolder as selected resource when select action is triggered', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
currentFolder: { id: 1 },
configuration: { options: { embed: { target: 'location' } } }
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => true),
postMessage: mockPostMessage
})

await wrapper.find(selectors.btnSelect).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:select',
data: [{ id: 1 }]
},
{}
)
})

it('should specify the targetOrigin when it is set in the config', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } }
currentFolder: { id: 1 }
})

await wrapper.find(selectors.btnSelect).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:select',
data: [{ id: 1 }]
},
{ targetOrigin: 'https://example.org' }
)
expect(mockPostMessage).toHaveBeenCalledWith('owncloud-embed:select', [{ id: 1 }])
})
})

describe('cancel action', () => {
it('should emit cancel event when the cancel action is triggered', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

await wrapper.find(selectors.btnCancel).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:cancel',
data: null
},
{}
)
})

it('should specify the targetOrigin when it is set in the config', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } }
})

await wrapper.find(selectors.btnCancel).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:cancel',
data: null
},
{ targetOrigin: 'https://example.org' }
)
expect(mockPostMessage).toHaveBeenCalledWith('owncloud-embed:cancel', null)
})
})

describe('share action', () => {
it('should disable share action when link creation is disabled', () => {
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => false),
postMessage: mockPostMessage
})

const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] })

expect(wrapper.find(selectors.btnShare).attributes()).toHaveProperty('disabled')
})

it('should disable share action when no resources are selected', () => {
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => false),
postMessage: mockPostMessage
})

const { wrapper } = getWrapper()

expect(wrapper.find(selectors.btnShare).attributes()).toHaveProperty('disabled')
})

it('should enable share action when at least one resource is selected and link creation is enabled', () => {
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => false),
postMessage: mockPostMessage
})

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
abilities: [{ action: 'create-all', subject: 'PublicLink' }]
Expand All @@ -169,8 +131,10 @@ describe('EmbedActions', () => {
})

it('should emit share event when share action is triggered', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => false),
postMessage: mockPostMessage
})

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
Expand All @@ -179,18 +143,14 @@ describe('EmbedActions', () => {

await wrapper.find(selectors.btnShare).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:share',
data: ['link-1']
},
{}
)
expect(mockPostMessage).toHaveBeenCalledWith('owncloud-embed:share', ['link-1'])
})

it('should ask for password first when required when share action is triggered', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => false),
postMessage: mockPostMessage
})

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
Expand All @@ -203,42 +163,18 @@ describe('EmbedActions', () => {

await wrapper.find(selectors.btnShare).trigger('click')

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:share',
data: ['password-link-1']
},
{}
)
expect(mockPostMessage).toHaveBeenCalledWith('owncloud-embed:share', ['password-link-1'])
})

it('should hide share action when embedTarget is set to location', () => {
const { wrapper } = getWrapper({
configuration: { options: { embed: { target: 'location' } } }
mockUseEmbedMode.mockReturnValue({
isLocationPicker: computed(() => true),
postMessage: mockPostMessage
})

expect(wrapper.find(selectors.btnShare).exists()).toBe(false)
})

it('should specify the targetOrigin when it is set in the config', async () => {
window.parent.postMessage = jest.fn()
global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent)

const { wrapper } = getWrapper({
selectedFiles: [{ id: 1 }],
configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } },
abilities: [{ action: 'create-all', subject: 'PublicLink' }]
})

await wrapper.find(selectors.btnShare).trigger('click')
const { wrapper } = getWrapper()

expect(window.parent.postMessage).toHaveBeenCalledWith(
{
name: 'owncloud-embed:share',
data: ['link-1']
},
{ targetOrigin: 'https://example.org' }
)
expect(wrapper.find(selectors.btnShare).exists()).toBe(false)
})
})
})
Expand All @@ -248,7 +184,6 @@ function getWrapper(
selectedFiles = [],
abilities = [],
capabilities = jest.fn().mockReturnValue({}),
configuration = { options: {} },
currentFolder = {},
defaultLinkPermissions = SharePermissionBit.Internal
} = {
Expand All @@ -262,8 +197,7 @@ function getWrapper(
...defaultStoreMockOptions,
getters: {
...defaultStoreMockOptions.getters,
capabilities,
configuration: jest.fn().mockReturnValue(configuration || { options: {} })
capabilities
},
modules: {
...defaultStoreMockOptions.modules,
Expand All @@ -287,7 +221,3 @@ function getWrapper(
})
}
}

function mockCustomEvent(name, payload) {
return { name, payload }
}
Loading