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
2 changes: 1 addition & 1 deletion packages/web-app-webfinger/src/views/Resolve.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default defineComponent({
} catch (e) {
console.error(e)
if (e.response?.status === 401) {
return authService.handleAuthError(unref(route), { forceLogout: true })
return authService.handleAuthError(unref(route))
}
hasError.value = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ 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>
reloadUserFromStorage(): Promise<void>
}

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
123 changes: 123 additions & 0 deletions packages/web-runtime/src/components/SessionExpiredModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<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'
import { loginWithPopupCoopFallback } from '../helpers/loginWithPopupCoopFallback'

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

try {
await loginWithPopupCoopFallback(() => authService.loginUserPopup())
dismiss()
} catch {
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
60 changes: 60 additions & 0 deletions packages/web-runtime/src/helpers/loginWithPopupCoopFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { authService } from '../services/auth'

const POPUP_COMPLETE_CHANNEL = 'oc_oidc_popup_complete'
const FALLBACK_TIMEOUT_MS = 120_000
// Grace period after the popup flow rejects, to let an already-dispatched COOP
// "complete" message (posted by the popup just before it closed) be delivered.
// If none arrives within this window, the popup was genuinely blocked or dismissed.
const POPUP_REJECTION_GRACE_MS = 2_000

/**
* Runs popup OIDC login and waits for the COOP BroadcastChannel fallback when the popup
* cannot use window.opener.
*
* A popup promise rejection does not fail the flow immediately: under COOP the opener
* handshake can reject even though the popup authenticated successfully and a "complete"
* message is already in flight. We wait a short grace period for that message before
* surfacing the failure, so a genuinely blocked popup still fails fast.
*/
export const loginWithPopupCoopFallback = (popupLogin: () => Promise<unknown>): Promise<void> => {
return new Promise((resolve, reject) => {
const bc = new BroadcastChannel(POPUP_COMPLETE_CHANNEL)
let settled = false
let graceTimeoutId: ReturnType<typeof setTimeout>

const finish = (callback: () => void) => {
if (settled) {
return
}
settled = true
clearTimeout(timeoutId)
clearTimeout(graceTimeoutId)
bc.close()
callback()
}

const timeoutId = setTimeout(() => {
finish(() => reject(new Error('popup_auth_timeout')))
}, FALLBACK_TIMEOUT_MS)

bc.addEventListener('message', async (e) => {
if (e.data?.type !== 'complete') {
return
}
try {
await authService.reloadUserFromStorage()
finish(resolve)
} catch (error) {
finish(() => reject(error))
}
})

popupLogin()
.then(() => finish(resolve))
.catch((error) => {
graceTimeoutId = setTimeout(() => {
finish(() => reject(error))
}, POPUP_REJECTION_GRACE_MS)
})
})
}
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