Skip to content

Commit

Permalink
feat: delegate auth in embed mode
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasHirt committed Nov 29, 2023
1 parent 4d63f1f commit 50ab483
Show file tree
Hide file tree
Showing 18 changed files with 703 additions and 186 deletions.
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

0 comments on commit 50ab483

Please sign in to comment.