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
4 changes: 4 additions & 0 deletions dev/docker/ocis.idp.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ clients:
- https://host.docker.internal:9200/
- https://host.docker.internal:9200/oidc-callback.html
- https://host.docker.internal:9200/oidc-silent-redirect.html
- https://host.docker.internal:9200/web-oidc-popup-callback
- https://host.docker.internal:9201/
- https://host.docker.internal:9201/oidc-callback.html
- https://host.docker.internal:9201/oidc-silent-redirect.html
- https://host.docker.internal:9201/web-oidc-popup-callback
- https://ocis.owncloud.test:10200/
- https://ocis.owncloud.test:10200/oidc-callback.html
- https://ocis.owncloud.test:10200/oidc-silent-redirect.html
- https://ocis.owncloud.test:10200/web-oidc-popup-callback
- https://ocis.owncloud.test:10201/
- https://ocis.owncloud.test:10201/oidc-callback.html
- https://ocis.owncloud.test:10201/oidc-silent-redirect.html
- https://ocis.owncloud.test:10201/web-oidc-popup-callback
origins:
- https://host.docker.internal:9200
- https://host.docker.internal:9201
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { useService } from '../service'
import { NavigationFailure } from 'vue-router'

export interface AuthServiceInterface {
handleAuthError(route: any, options?: { forceLogout?: boolean }): any
handleAuthError(route: any): any
signinSilent(): Promise<unknown>
logoutUser(): Promise<void | NavigationFailure>
getRefreshToken(): Promise<string>
showSessionExpiredModal(): void
loginUserPopup(): Promise<unknown>
}

export const useAuthService = (): AuthServiceInterface => {
Expand Down
6 changes: 6 additions & 0 deletions packages/web-pkg/src/composables/piniaStores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string>()
const idpContextReady = ref(false)
const userContextReady = ref(false)
const sessionExpired = ref(false)
const publicLinkToken = ref<string>()
const publicLinkPassword = ref<string>()
const publicLinkType = ref<string>()
Expand All @@ -19,6 +20,9 @@ export const useAuthStore = defineStore('auth', () => {
const setUserContextReady = (value: boolean) => {
userContextReady.value = value
}
const setSessionExpired = (value: boolean) => {
sessionExpired.value = value
}
const setPublicLinkContext = (context: {
publicLinkToken: string
publicLinkPassword: string
Expand Down Expand Up @@ -50,6 +54,7 @@ export const useAuthStore = defineStore('auth', () => {
accessToken,
idpContextReady,
userContextReady,
sessionExpired,
publicLinkToken,
publicLinkPassword,
publicLinkType,
Expand All @@ -58,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
setAccessToken,
setIdpContextReady,
setUserContextReady,
setSessionExpired,
setPublicLinkContext,
clearUserContext,
clearPublicLinkContext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ export const useTokenTimerWorker = ({ authService }: { authService: AuthServiceI

console.error('token renewal error:', error)

// log out user if they don't have a refresh token
// show session expired modal if there's no refresh token to renew silently
const refreshToken = await authService.getRefreshToken()
if (!refreshToken) {
return authService.logoutUser()
return authService.showSessionExpiredModal()
}
})
}
Expand Down
5 changes: 4 additions & 1 deletion packages/web-runtime/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
</skip-to>
<component :is="layout"></component>
<modal-wrapper />
<session-expired-modal />
</div>
</template>
<script lang="ts">
import SkipTo from './components/SkipTo.vue'
import ModalWrapper from './components/ModalWrapper.vue'
import SessionExpiredModal from './components/SessionExpiredModal.vue'
import { useLayout } from './composables/layout'
import { computed, defineComponent, unref, watch } from 'vue'
import { additionalTranslations } from './helpers/additionalTranslations' // eslint-disable-line
Expand All @@ -24,7 +26,8 @@ import { isEqual } from 'lodash-es'
export default defineComponent({
components: {
SkipTo,
ModalWrapper
ModalWrapper,
SessionExpiredModal
},
setup() {
const resourcesStore = useResourcesStore()
Expand Down
136 changes: 136 additions & 0 deletions packages/web-runtime/src/components/SessionExpiredModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<template>
<div v-if="sessionExpired" class="session-expired-overlay">
<div class="oc-login-card session-expired-card">
<router-link to="/" aria-label="Home">
<img class="oc-login-logo" :src="logoImg" alt="" :aria-hidden="true" />
</router-link>
<div class="oc-login-card-body oc-width-medium">
<h2 class="oc-login-card-title" v-text="$gettext('Session expired')" />
<p
v-if="popupBlocked"
v-text="$gettext('Popup was blocked. Please allow popups for this page in your browser and try again.')"
/>
<p
v-else
v-text="
$gettext(
'Your session has expired. If you are logged in on another tab, this page will resume automatically. Otherwise, click Reconnect to log in again.'
)
"
/>
</div>
<div class="oc-login-card-footer oc-pt-rm">
<p>{{ footerSlogan }}</p>
</div>
</div>
<oc-button
class="oc-mt-m oc-width-medium"
size="large"
appearance="filled"
variation="primary"
:disabled="reconnecting"
@click="reconnect"
>
{{ reconnecting ? $gettext('Opening login…') : $gettext('Reconnect') }}
</oc-button>
</div>
</template>

<script lang="ts">
import { computed, defineComponent, onMounted, onUnmounted, ref } from 'vue'
import { useAuthService, useAuthStore, useRouter, useThemeStore } from '@ownclouders/web-pkg'
import { storeToRefs } from 'pinia'

export default defineComponent({
name: 'SessionExpiredModal',
setup() {
const authService = useAuthService()
const authStore = useAuthStore()
const themeStore = useThemeStore()
const { currentTheme } = storeToRefs(themeStore)
const { sessionExpired } = storeToRefs(authStore)

const reconnecting = ref(false)
const popupBlocked = ref(false)
const logoImg = computed(() => currentTheme.value?.logo?.login)
const footerSlogan = computed(() => currentTheme.value?.common?.slogan)

const router = useRouter()

const authRoutes = new Set([
'login', 'logout', 'oidcCallback', 'oidcSilentRedirect', 'oidcPopupCallback', 'accessDenied'
])

const dismiss = () => {
reconnecting.value = false
popupBlocked.value = false
authStore.setSessionExpired(false)
// If reconnect succeeded while on a transient auth page, go home
const currentName = router.currentRoute.value?.name as string
if (authRoutes.has(currentName)) {
router.replace('/')
}
}

const handleStorageEvent = (event: StorageEvent) => {
if (!event.key?.startsWith('oc_oAuth.') || !event.newValue) {
return
}
// Only attempt cross-tab reconnect when the session is actually expired
if (!authStore.sessionExpired) {
return
}
authService.signinSilent().then(dismiss).catch(() => {})
}

onMounted(() => window.addEventListener('storage', handleStorageEvent))
onUnmounted(() => window.removeEventListener('storage', handleStorageEvent))

const reconnect = async () => {
reconnecting.value = true
popupBlocked.value = false

// BroadcastChannel handles the COOP fallback where window.opener is null
const bc = new BroadcastChannel('oc_oidc_popup_complete')
const coopFallback = new Promise<void>((resolve) => {
bc.addEventListener('message', async (e) => {
if (e.data?.type === 'complete') {
bc.close()
await authService.reloadUserFromStorage()
resolve()
}
})
})

try {
await Promise.race([authService.loginUserPopup(), coopFallback])
bc.close()
dismiss()
} catch {
bc.close()
reconnecting.value = false
popupBlocked.value = true
}
}

return { sessionExpired, logoImg, footerSlogan, reconnecting, popupBlocked, reconnect }
}
})
</script>

<style lang="scss" scoped>
.session-expired-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.session-expired-card {
text-align: center;
}
</style>
1 change: 1 addition & 0 deletions packages/web-runtime/src/composables/layout/useLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const useLayout = (options?: LayoutOptions) => {
'logout',
'oidcCallback',
'oidcSilentRedirect',
'oidcPopupCallback',
'resolvePublicLink',
'accessDenied'
]
Expand Down
1 change: 1 addition & 0 deletions packages/web-runtime/src/helpers/silentRedirect.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const isSilentRedirectRoute = () => window.location.pathname === '/web-oidc-silent-redirect'
export const isPopupCallbackRoute = () => window.location.pathname === '/web-oidc-popup-callback'
43 changes: 21 additions & 22 deletions packages/web-runtime/src/pages/accessDenied.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<template>
<div class="oc-height-viewport oc-flex oc-flex-column oc-flex-center oc-flex-middle">
<div class="oc-login-card">
<img class="oc-login-logo" :src="logoImg" alt="" :aria-hidden="true" />
<router-link to="/" aria-label="Home">
<img class="oc-login-logo" :src="logoImg" alt="" :aria-hidden="true" />
</router-link>
<div class="oc-login-card-body oc-width-medium">
<h2 class="oc-login-card-title" v-text="cardTitle" />
<p v-text="cardHint" />
Expand Down Expand Up @@ -51,44 +53,41 @@ export default defineComponent({
const { currentTheme } = storeToRefs(themeStore)
const configStore = useConfigStore()
const redirectUrlQuery = useRouteQuery('redirectUrl')
const reasonQuery = useRouteQuery('reason')

const { $gettext } = useGettext()

const isLoginError = computed(() => queryItemAsString(unref(reasonQuery)) === 'loginError')

const accessDeniedHelpUrl = computed(() => currentTheme.value.common.urls.accessDeniedHelp)
const footerSlogan = computed(() => currentTheme.value.common.slogan)
const logoImg = computed(() => currentTheme.value.logo.login)

const cardTitle = computed(() => {
return $gettext('Not logged in')
})
const cardHint = computed(() => {
return $gettext(
'This could be because of a routine safety log out, or because your account is either inactive or not yet authorized for use. Please try logging in after a while or seek help from your Administrator.'
)
})
const navigateToLoginText = computed(() => {
return $gettext('Log in again')
})
const cardTitle = computed(() =>
unref(isLoginError) ? $gettext('Error signing in') : $gettext('Not logged in')
)
const cardHint = computed(() =>
unref(isLoginError)
? $gettext(
'There was an error while trying to sign you in. Please try again or contact your administrator if the problem persists.'
)
: $gettext(
'This could be because of a routine safety log out, or because your account is either inactive or not yet authorized for use. Please try logging in after a while or seek help from your Administrator.'
)
)
const navigateToLoginText = computed(() => $gettext('Log in again'))
const logoutButtonsAttrs = computed(() => {
const redirectUrl = queryItemAsString(unref(redirectUrlQuery))
if (configStore.options.loginUrl) {
const configLoginURL = new URL(encodeURI(configStore.options.loginUrl))
if (redirectUrl) {
configLoginURL.searchParams.append('redirectUrl', redirectUrl)
}
return {
type: 'a',
href: configLoginURL.toString()
}
return { type: 'a', href: configLoginURL.toString() }
}
return {
type: 'router-link',
to: {
name: 'login',
query: {
...(redirectUrl && { redirectUrl })
}
}
to: { name: 'login', query: { ...(redirectUrl && { redirectUrl }) } }
}
})

Expand Down
Loading