- @{{ loggedInUser && loggedInUser.preferred_username }}
+ @{{ loggedInUser && loggedInUser.username }}
{{ $t('account.editProfile') }}
@@ -163,7 +163,6 @@
import { BNav } from 'bootstrap-vue';
import { mapState } from 'vuex';
- import keycloak from '../../mixins/keycloak';
import pageMetaMixin from '@/mixins/pageMeta';
import ItemPreviewCardGroup from '../../components/item/ItemPreviewCardGroup';
import UserSets from '../../components/user/UserSets';
@@ -182,7 +181,6 @@
},
mixins: [
- keycloak,
pageMetaMixin
],
@@ -190,7 +188,6 @@
data() {
return {
- loggedInUser: this.$store.state.auth.user,
tabHashes: {
likes: '#likes',
publicGalleries: '#public-galleries',
@@ -214,12 +211,13 @@
};
},
userIsEditor() {
- return this.$auth.userHasClientRole('entities', 'editor') &&
- this.$auth.userHasClientRole('usersets', 'editor');
+ return this.$store.getters['keycloak/userHasClientRole']('entities', 'editor') &&
+ this.$store.getters['keycloak/userHasClientRole']('usersets', 'editor');
},
...mapState({
likesId: state => state.set.likesId,
likedItems: state => state.set.likedItems,
+ loggedInUser: state => state.keycloak.profile,
curations: state => state.set.curations
}),
activeTab() {
diff --git a/packages/portal/src/pages/account/login.vue b/packages/portal/src/pages/account/login.vue
index cb870c873f..00a2fde77d 100644
--- a/packages/portal/src/pages/account/login.vue
+++ b/packages/portal/src/pages/account/login.vue
@@ -3,19 +3,14 @@
diff --git a/packages/portal/src/pages/account/logout.vue b/packages/portal/src/pages/account/logout.vue
index f68e5daa0a..04f00f0e06 100644
--- a/packages/portal/src/pages/account/logout.vue
+++ b/packages/portal/src/pages/account/logout.vue
@@ -6,27 +6,11 @@
export default {
name: 'AccountLogoutPage',
- beforeRouteEnter(to, from, next) {
- next(vm => {
- const redirectPath = /^account___[a-z]{2}$/.test(from.name) ? `/${vm.$i18n.locale}` : from.fullPath;
- vm.$auth.$storage.setUniversal('redirect', redirectPath);
- });
- },
-
layout: 'minimal',
- created() {
- this.$auth.$storage.setUniversal('portalLoggingOut', true);
- },
-
mounted() {
- this.$auth.logout({ params: { 'ui_locales': this.$i18n.locale } });
- localStorage.setItem('logout-event', `logout-${Math.random()}`);
-
- const path = this.$auth.strategies.keycloak.options.end_session_endpoint;
- const redirect = window.location.origin + this.$auth.$storage.getUniversal('redirect');
-
- window.location.assign(`${path}?redirect_uri=${encodeURIComponent(redirect)}`);
+ localStorage['kc.logout'] = 'true';
+ this.$keycloak.logout();
}
};
diff --git a/packages/portal/src/pages/collections/_type/_.vue b/packages/portal/src/pages/collections/_type/_.vue
index bad31fb0c8..69fa4b56e1 100644
--- a/packages/portal/src/pages/collections/_type/_.vue
+++ b/packages/portal/src/pages/collections/_type/_.vue
@@ -229,10 +229,10 @@
['topic', 'organisation'].includes(this.collectionType);
},
userIsEntitiesEditor() {
- return this.$auth.userHasClientRole('entities', 'editor');
+ return this.$store.getters['keycloak/userHasClientRole']('entities', 'editor');
},
userIsSetsEditor() {
- return this.$auth.userHasClientRole('usersets', 'editor');
+ return this.$store.getters['keycloak/userHasClientRole']('usersets', 'editor');
},
route() {
return {
diff --git a/packages/portal/src/pages/galleries/_.vue b/packages/portal/src/pages/galleries/_.vue
index 0a2ed686a8..adf151c151 100644
--- a/packages/portal/src/pages/galleries/_.vue
+++ b/packages/portal/src/pages/galleries/_.vue
@@ -272,15 +272,15 @@
return this.set.creator && typeof this.set.creator === 'string' ? this.set.creator : this.set.creator.id;
},
userIsOwner() {
- return this.$auth.loggedIn && this.$auth.user &&
- this.setCreatorId?.endsWith(`/${this.$auth.user.sub}`);
+ return this.$store.state.keycloak.loggedIn && this.$store.state.keycloak.profile &&
+ this.setCreatorId?.endsWith(`/${this.$store.state.keycloak.profile.id}`);
},
userIsEntityEditor() {
- return this.$auth.userHasClientRole('entities', 'editor') &&
- this.$auth.userHasClientRole('usersets', 'editor');
+ return this.$store.getters['keycloak/userHasClientRole']('entities', 'editor') &&
+ this.$store.getters['keycloak/userHasClientRole']('usersets', 'editor');
},
userIsPublisher() {
- return this.$auth.userHasClientRole('usersets', 'publisher');
+ return this.$store.getters['keycloak/userHasClientRole']('usersets', 'publisher');
},
userCanHandleRecommendations() {
return this.userIsOwner || (this.setIsEntityBestItems && this.userIsEntityEditor);
@@ -300,7 +300,7 @@
return this.set.type === 'EntityBestItemsSet';
},
displayRecommendations() {
- return this.enableRecommendations && this.$auth.loggedIn && this.userCanHandleRecommendations;
+ return this.enableRecommendations && this.$store.state.keycloak.loggedIn && this.userCanHandleRecommendations;
},
enableRecommendations() {
if (this.setIsEntityBestItems) {
diff --git a/packages/portal/src/plugins/authScheme.js b/packages/portal/src/plugins/authScheme.js
deleted file mode 100644
index 8a4f68be67..0000000000
--- a/packages/portal/src/plugins/authScheme.js
+++ /dev/null
@@ -1,37 +0,0 @@
-// Custom Nuxt auth scheme extending oAuth2 scheme to support Nuxt runtime config
-
-// TODO: delete once auth module supports Nuxt runtime config
-// @see https://github.com/nuxt-community/auth-module/issues/713
-
-// When Nuxt is built, this custom auth plugin will end up in .nuxt/auth/schemes,
-// as will @nuxtjs/auth/lib/schemes/oauth2.js if it's also a registered strategy
-// in the auth module config (in nuxt.config.js).
-import Oauth2Scheme from './oauth2';
-
-const keycloakOpenIDConnectEndpoint = (method, { realm, origin }) =>
- `${origin}/auth/realms/${realm}/protocol/openid-connect/${method}`;
-
-export function userHasClientRole(client, role) {
- return this.user?.resource_access?.[client]?.roles?.includes(role) || false;
-}
-
-// Inspired by https://github.com/nuxt-community/auth-module/issues/713#issuecomment-724031930
-export default class RuntimeConfigurableOauth2Scheme extends Oauth2Scheme {
- constructor($auth, options) {
- const configOptions = {
- ...options,
- ...$auth.ctx?.$config?.auth?.strategies[options['_name']]
- };
-
- configOptions['authorization_endpoint'] = keycloakOpenIDConnectEndpoint('auth', configOptions);
- configOptions['access_token_endpoint'] = keycloakOpenIDConnectEndpoint('token', configOptions);
- configOptions['userinfo_endpoint'] = keycloakOpenIDConnectEndpoint('userinfo', configOptions);
- configOptions['end_session_endpoint'] = keycloakOpenIDConnectEndpoint('logout', configOptions);
-
- if (typeof $auth.userHasClientRole !== 'function') {
- $auth.userHasClientRole = userHasClientRole;
- }
-
- super($auth, configOptions);
- }
-}
diff --git a/packages/portal/src/plugins/europeana/auth.js b/packages/portal/src/plugins/europeana/auth.js
deleted file mode 100644
index 9931138c6a..0000000000
--- a/packages/portal/src/plugins/europeana/auth.js
+++ /dev/null
@@ -1,104 +0,0 @@
-// @see https://github.com/nuxt-community/auth-module/blob/v4.9.1/lib/schemes/oauth2.js#L157-L201
-const refreshAccessToken = async({ $auth, $axios, redirect, route }, requestConfig) => {
- let refreshAccessTokenResponse;
- try {
- refreshAccessTokenResponse = await $auth.request(refreshAccessTokenRequestOptions($auth));
- } catch {
- // Refresh token is no longer valid; clear tokens and try again
- $auth.logout();
- delete requestConfig.headers['Authorization'];
- return $axios.request(requestConfig);
- }
-
- if (!updateAccessToken($auth, requestConfig, refreshAccessTokenResponse)) {
- // No new access token; redirect to login URL
- return redirect($auth.options.redirect.login, { redirect: route.path });
- }
-
- updateRefreshToken($auth, refreshAccessTokenResponse);
-
- // Retry request with new access token
- return $axios.request(requestConfig);
-};
-
-const updateRefreshToken = ($auth, refreshAccessTokenResponse) => {
- const options = $auth.strategy.options;
-
- let newRefreshToken = refreshAccessTokenResponse[options.refresh_token_key];
- if (!newRefreshToken) {
- return false;
- }
-
- if (options.token_type) {
- newRefreshToken = `${options.token_type} ${newRefreshToken}`;
- }
-
- // Store refresh token
- $auth.setRefreshToken($auth.strategy.name, newRefreshToken);
-
- return newRefreshToken;
-};
-
-const updateAccessToken = ($auth, requestConfig, refreshAccessTokenResponse) => {
- const options = $auth.strategy.options;
-
- let newAccessToken = refreshAccessTokenResponse[options.token_key];
- if (!newAccessToken) {
- return false;
- }
-
- if (options.token_type) {
- newAccessToken = `${options.token_type} ${newAccessToken}`;
- }
-
- // Store token
- $auth.setToken($auth.strategy.name, newAccessToken);
-
- // Set axios token
- $auth.strategy._setToken(newAccessToken); // eslint-disable-line no-underscore-dangle
-
- delete requestConfig.headers['Authorization'];
-
- return newAccessToken;
-};
-
-const refreshAccessTokenRequestOptions = ($auth) => {
- const refreshToken = $auth.getRefreshToken($auth.strategy.name);
- const options = $auth.strategy.options;
- // Nuxt Auth stores token type e.g. "Bearer " with token, but refresh_token
- // grant does not need it; remove it before sending to OIDC.
- const refreshTokenWithoutType = refreshToken.replace(new RegExp(`^${options.token_type} `), '');
-
- return {
- method: 'post',
- url: options.access_token_endpoint,
- headers: {
- 'content-type': 'application/x-www-form-urlencoded'
- },
- data: new URLSearchParams({
- 'client_id': options.client_id,
- 'refresh_token': refreshTokenWithoutType,
- 'grant_type': 'refresh_token'
- }).toString()
- };
-};
-
-export const keycloakResponseErrorHandler = (context, error) => {
- if (error.response?.status === 401) {
- return keycloakUnauthorizedResponseErrorHandler(context, error);
- } else {
- return Promise.reject(error);
- }
-};
-
-const keycloakUnauthorizedResponseErrorHandler = ({ $auth, $axios, redirect, route }, error) => {
- if ($auth.getRefreshToken($auth.strategy.name)) {
- // User has previously logged in, and we have a refresh token, e.g.
- // access token has expired
- return refreshAccessToken({ $auth, $axios, redirect, route }, error.config);
- } else {
- // User has not already logged in, or we have no refresh token:
- // redirect to OIDC login URL
- return redirect($auth.options.redirect.login, { redirect: route.path });
- }
-};
diff --git a/packages/portal/src/plugins/europeana/utils.js b/packages/portal/src/plugins/europeana/utils.js
index ec8e71c129..b4038b2dfe 100644
--- a/packages/portal/src/plugins/europeana/utils.js
+++ b/packages/portal/src/plugins/europeana/utils.js
@@ -2,7 +2,6 @@ import axios from 'axios';
import qs from 'qs';
import locales from '../i18n/locales.js';
-import { keycloakResponseErrorHandler } from './auth.js';
export const createAxios = ({ id, baseURL, $axios } = {}, context = {}) => {
const axiosOptions = axiosInstanceOptions({ id, baseURL }, context);
@@ -20,9 +19,7 @@ export const createAxios = ({ id, baseURL, $axios } = {}, context = {}) => {
export const createKeycloakAuthAxios = ({ id, baseURL, $axios }, context) => {
const axiosInstance = createAxios({ id, baseURL, $axios }, context);
- if (typeof axiosInstance.onResponseError === 'function') {
- axiosInstance.onResponseError(error => keycloakResponseErrorHandler(context, error));
- }
+ context.$keycloak?.axios?.(axiosInstance);
return axiosInstance;
};
diff --git a/packages/portal/src/plugins/keycloak.js b/packages/portal/src/plugins/keycloak.js
new file mode 100644
index 0000000000..071e2370e4
--- /dev/null
+++ b/packages/portal/src/plugins/keycloak.js
@@ -0,0 +1,211 @@
+// TODO: move to new workspace pkg?
+
+// docs: https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter
+import Keycloak from 'keycloak-js';
+
+const keycloakAxios = (ctx) => (axiosInstance) => {
+ axiosInstance.interceptors.request.use((requestConfig) => {
+ if (ctx.$keycloak.keycloak?.token) {
+ requestConfig.headers.authorization = `Bearer ${ctx.$keycloak.keycloak.token}`;
+ }
+ return requestConfig;
+ });
+
+ if (typeof axiosInstance.onResponseError === 'function') {
+ axiosInstance.onResponseError(error => keycloakResponseErrorHandler(ctx, error));
+ }
+};
+
+const keycloakResponseErrorHandler = (ctx, error) => {
+ if (error.response?.status === 401) {
+ return keycloakUnauthorizedResponseErrorHandler(ctx, error);
+ } else {
+ return Promise.reject(error);
+ }
+};
+
+const keycloakUnauthorizedResponseErrorHandler = (ctx, error) => {
+ if (ctx.$keycloak.keycloak.refreshToken) {
+ // User has previously logged in, and we have a refresh token, e.g.
+ // access token has expired
+ return keycloakRefreshAccessToken(ctx, error.config);
+ } else {
+ // User has not already logged in, or we have no refresh token:
+ // redirect to OIDC login URL
+ return ctx.redirect('/account/login', { redirect: ctx.route.path });
+ }
+};
+
+const keycloakRefreshAccessToken = async(ctx, requestConfig) => {
+ const updated = await ctx.$keycloak.keycloak.updateToken(-1);
+ if (updated) {
+ ctx.$cookies.set('kc.token', ctx.$keycloak.keycloak.token);
+ ctx.$cookies.set('kc.idToken', ctx.$keycloak.keycloak.idToken);
+ ctx.$cookies.set('kc.refreshToken', ctx.$keycloak.keycloak.refreshToken);
+ } else {
+ // Refresh token is no longer valid; clear tokens and try again in case it
+ // doesn't require auth anyway
+ ctx.$keycloak.keycloak.clearToken();
+ }
+
+ // Retry request with new access token
+ return ctx.$axios.request(requestConfig);
+};
+
+const storeModule = {
+ namespaced: true,
+
+ state: () => ({
+ loggedIn: false,
+ profile: {},
+ resourceAccess: {}
+ }),
+
+ mutations: {
+ setLoggedIn(state, value) {
+ state.loggedIn = value;
+ },
+
+ setProfile(state, value) {
+ state.profile = value;
+ },
+
+ setResourceAccess(state, value) {
+ state.resourceAccess = value;
+ }
+ },
+
+ getters: {
+ userHasClientRole: (state) => (client, role) => {
+ return state.resourceAccess[client]?.roles?.includes(role);
+ }
+ }
+};
+
+const plugin = (ctx) => ({
+ // TODO: use this.keycloak.createLoginUrl instead
+ get accountUrl() {
+ const keycloakAccountUrl = new URL(ctx.$config.keycloak.url);
+
+ keycloakAccountUrl.pathname = `${keycloakAccountUrl.pathname}/realms/${ctx.$config.keycloak.realm}/account`;
+ if (keycloakAccountUrl.pathname.startsWith('//')) {
+ keycloakAccountUrl.pathname = keycloakAccountUrl.pathname.slice(1);
+ }
+
+ const referrerUri = new URL(ctx.$config.app.baseUrl);
+ referrerUri.pathname = ctx.route.path;
+ referrerUri.search = new URLSearchParams(ctx.route.query).toString();
+ referrerUri.hash = ctx.route.hash;
+
+ keycloakAccountUrl.search = new URLSearchParams({
+ referrer: ctx.$config.keycloak.clientId,
+ 'referrer_uri': referrerUri.toString()
+ }).toString();
+
+ return keycloakAccountUrl.toString();
+ },
+ axios: keycloakAxios(ctx),
+ callback() {
+ let redirect = '/';
+
+ if (ctx.route.query.redirect?.startsWith('/')) {
+ redirect = ctx.route.query.redirect;
+ }
+
+ ctx.app.router.replace(redirect);
+ },
+ async init() {
+ try {
+ await this.keycloak.init({
+ checkLoginIframe: false,
+ token: ctx.$cookies.get('kc.token'),
+ idToken: ctx.$cookies.get('kc.idToken'),
+ refreshToken: ctx.$cookies.get('kc.refreshToken')
+ });
+ } catch (e) {
+ ctx.$cookies.remove('kc.token');
+ ctx.$cookies.remove('kc.idToken');
+ ctx.$cookies.remove('kc.refreshToken');
+ await this.keycloak.init({
+ checkLoginIframe: false
+ });
+ }
+
+ ctx.store.commit('keycloak/setLoggedIn', this.keycloak.authenticated);
+
+ ctx.$cookies.set('kc.token', this.keycloak.token);
+ ctx.$cookies.set('kc.idToken', this.keycloak.idToken);
+ ctx.$cookies.set('kc.refreshToken', this.keycloak.refreshToken);
+
+ if (this.keycloak.authenticated) {
+ const profile = await this.keycloak.loadUserProfile();
+ ctx.store.commit('keycloak/setProfile', profile);
+ ctx.store.commit('keycloak/setResourceAccess', this.keycloak.resourceAccess);
+ }
+ },
+ keycloak: process.client && new Keycloak(ctx.$config.keycloak),
+ login() {
+ this.keycloak.login({
+ locale: ctx.i18n.locale,
+ redirectUri: this.loginRedirect
+ });
+ },
+ get loginRedirect() {
+ let redirectPath = ctx.localePath('/account');
+
+ if (ctx.route) {
+ if ((ctx.route.query?.redirect || '').startsWith('/')) {
+ redirectPath = ctx.route.query.redirect;
+ } else if (ctx.route.path === ctx.localePath('/account/login')) {
+ redirectPath = ctx.localePath('/account');
+ } else {
+ redirectPath = ctx.route.fullPath;
+ }
+ }
+
+ const redirectUrl = new URL(`${ctx.$config.app.baseUrl}${ctx.localePath('/account/callback')}`);
+ redirectUrl.searchParams.set('redirect', redirectPath);
+
+ return redirectUrl.toString();
+ },
+ logout() {
+ this.keycloak.logout({
+ redirectUri: this.logoutRedirect,
+ 'ui_locales': ctx.i18n.locale
+ });
+ },
+ get logoutRedirect() {
+ let redirectPath = ctx.localePath('/');
+
+ if ((ctx.route.query?.redirect || '').startsWith('/')) {
+ redirectPath = ctx.route.query.redirect;
+ } else if (ctx.route.fullPath) {
+ redirectPath = ctx.route.fullPath;
+ }
+
+ const redirectUrl = new URL(`${ctx.$config.app.baseUrl}${ctx.localePath('/account/callback')}`);
+ redirectUrl.searchParams.set('redirect', redirectPath);
+
+ return redirectUrl.toString();
+ },
+ get logoutRoute() {
+ let redirect = '/';
+ if (!ctx.route.name.startsWith('account')) {
+ redirect = ctx.route.fullPath;
+ }
+ return {
+ name: 'account-logout',
+ query: {
+ redirect
+ }
+ };
+ }
+});
+
+export default async(ctx, inject) => {
+ ctx.store.registerModule('keycloak', storeModule);
+
+ ctx.store.commit('keycloak/setLoggedIn', !!ctx.$cookies.get('kc.token'));
+
+ inject('keycloak', await plugin(ctx));
+};
diff --git a/packages/portal/src/plugins/oauth2.js b/packages/portal/src/plugins/oauth2.js
deleted file mode 100644
index 7dbe84fdea..0000000000
--- a/packages/portal/src/plugins/oauth2.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export default class Oauth2Scheme {
- constructor() {
- // Dummy class to support testing of ./authScheme.js
- }
-}
diff --git a/packages/portal/src/plugins/user-likes.client.js b/packages/portal/src/plugins/user-likes.client.js
deleted file mode 100644
index 6772c8c1b4..0000000000
--- a/packages/portal/src/plugins/user-likes.client.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export default async({ $auth, store }) => {
- if ($auth?.loggedIn) {
- try {
- // TODO: assess whether there is a more efficient way to do this with fewer
- // API requests
- await store.dispatch('set/setLikes');
- await store.dispatch('set/fetchLikes');
- } catch (e) {
- // Don't cause everything to break if the Set API is down...
- }
- }
-};
diff --git a/packages/portal/src/store/set.js b/packages/portal/src/store/set.js
index 84a2a86371..cde48d4053 100644
--- a/packages/portal/src/store/set.js
+++ b/packages/portal/src/store/set.js
@@ -82,8 +82,8 @@ export default {
async removeItem(ctx, { setId, itemId }) {
await this.$apis.set.modifyItems('delete', setId, itemId);
},
- async setLikes({ commit }) {
- const likesId = await this.$apis.set.getLikes(this.$auth.user ? this.$auth.user.sub : null);
+ async setLikes({ commit, rootState }) {
+ const likesId = await this.$apis.set.getLikes(rootState.keycloak?.profile?.id || null);
commit('setLikesId', likesId);
},
async createLikes({ commit }) {