diff --git a/.helm/ecamp3/templates/api_configmap.yaml b/.helm/ecamp3/templates/api_configmap.yaml index 589c51d203..055aa233d9 100644 --- a/.helm/ecamp3/templates/api_configmap.yaml +++ b/.helm/ecamp3/templates/api_configmap.yaml @@ -7,6 +7,9 @@ metadata: {{- include "app.commonLabels" . | nindent 4 }} data: ADDITIONAL_TRUSTED_HOSTS: {{ .Values.domain | quote }} + {{- if not (.Values.api.authenticationTokenTtl | empty) }} + AUTHENTICATION_TOKEN_TTL: {{ .Values.api.authenticationTokenTtl | quote }} + {{- end }} COOKIE_PREFIX: {{ include "api.cookiePrefix" . | quote }} APP_ENV: {{ .Values.api.appEnv | quote }} APP_DEBUG: {{ .Values.api.appDebug | quote }} diff --git a/.helm/ecamp3/values.yaml b/.helm/ecamp3/values.yaml index 58a0342d8f..e36f706d05 100644 --- a/.helm/ecamp3/values.yaml +++ b/.helm/ecamp3/values.yaml @@ -18,6 +18,7 @@ featureToggle: checklist: false # enables checklist feature in frontend api: + authenticationTokenTtl: subpath: "/api" image: repository: "docker.io/ecamp/ecamp3-api" diff --git a/api/.env b/api/.env index cf58c589b6..661319cbca 100644 --- a/api/.env +++ b/api/.env @@ -81,4 +81,6 @@ MAIL_FROM_NAME="eCamp v3" RECAPTCHA_SECRET="disabled" ###< google/recaptcha ### +# Tokens are valid for 12 hours.. +AUTHENTICATION_TOKEN_TTL=43200 TRANSLATE_ERRORS_TO_LOCALES="en,de,fr,it,rm" diff --git a/api/composer.json b/api/composer.json index a08ce2cbb3..b4168da572 100644 --- a/api/composer.json +++ b/api/composer.json @@ -23,6 +23,7 @@ "exercise/htmlpurifier-bundle": "5.1", "friendsofsymfony/http-cache": "3.1.1", "friendsofsymfony/http-cache-bundle": "3.2.0", + "gesdinet/jwt-refresh-token-bundle": "1.5.0", "google/recaptcha": "1.3.1", "guzzlehttp/guzzle": "7.10.0", "knpuniversity/oauth2-client-bundle": "2.18.4", diff --git a/api/composer.lock b/api/composer.lock index 824ea9ac5d..ba95f57a1a 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fa180abac53ec02e32860392dca5a40b", + "content-hash": "d0305ff3ccf8b1e8903564b46aa973c7", "packages": [ { "name": "api-platform/doctrine-common", @@ -3286,6 +3286,86 @@ ], "time": "2025-04-04T17:19:27+00:00" }, + { + "name": "gesdinet/jwt-refresh-token-bundle", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/markitosgv/JWTRefreshTokenBundle.git", + "reference": "8706b0d8dcb26610358ba3328ec412315b55c3cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/markitosgv/JWTRefreshTokenBundle/zipball/8706b0d8dcb26610358ba3328ec412315b55c3cd", + "reference": "8706b0d8dcb26610358ba3328ec412315b55c3cd", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^1.3.3|^2.0|^3.0|^4.0", + "lexik/jwt-authentication-bundle": "^2.0|^3.0", + "php": ">=7.4", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/deprecation-contracts": "^2.1|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/polyfill-php80": "^1.15", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/security-http": "^5.4|^6.0|^7.0" + }, + "conflict": { + "doctrine/mongodb-odm": "<2.2", + "doctrine/orm": "<2.7" + }, + "require-dev": { + "doctrine/annotations": "^1.13|^2.0", + "doctrine/cache": "^1.11|^2.0", + "doctrine/mongodb-odm": "^2.2", + "doctrine/orm": "^2.7|^3.0", + "matthiasnoback/symfony-config-test": "^4.2|^5.0", + "matthiasnoback/symfony-dependency-injection-test": "^4.2|^5.0", + "phpunit/phpunit": "^9.5", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/security-guard": "^5.4" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Gesdinet\\JWTRefreshTokenBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcos Gómez Vilches", + "email": "marcos@gesdinet.com" + } + ], + "description": "Implements a refresh token system over Json Web Tokens in Symfony", + "keywords": [ + "jwt refresh token bundle symfony json web" + ], + "support": { + "issues": "https://github.com/markitosgv/JWTRefreshTokenBundle/issues", + "source": "https://github.com/markitosgv/JWTRefreshTokenBundle/tree/v1.5.0" + }, + "time": "2025-06-24T13:08:37+00:00" + }, { "name": "google/recaptcha", "version": "1.3.1", diff --git a/api/config/bundles.php b/api/config/bundles.php index 8df45f3cfd..1c9079527d 100644 --- a/api/config/bundles.php +++ b/api/config/bundles.php @@ -6,6 +6,7 @@ use Exercise\HTMLPurifierBundle\ExerciseHTMLPurifierBundle; use Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle; use FOS\HttpCacheBundle\FOSHttpCacheBundle; +use Gesdinet\JWTRefreshTokenBundle\GesdinetJWTRefreshTokenBundle; use Hautelook\AliceBundle\HautelookAliceBundle; use KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; @@ -44,4 +45,5 @@ SentryBundle::class => ['all' => true], TwigExtraBundle::class => ['all' => true], FOSHttpCacheBundle::class => ['all' => true], + GesdinetJWTRefreshTokenBundle::class => ['all' => true], ]; diff --git a/api/config/packages/gesdinet_jwt_refresh_token.yaml b/api/config/packages/gesdinet_jwt_refresh_token.yaml new file mode 100644 index 0000000000..b10f394de4 --- /dev/null +++ b/api/config/packages/gesdinet_jwt_refresh_token.yaml @@ -0,0 +1,11 @@ +gesdinet_jwt_refresh_token: + cookie: + enabled: true + same_site: strict + path: / + http_only: true + secure: '%env(bool:COOKIE_SECURE)%' + remove_token_from_body: true + refresh_token_class: App\Entity\RefreshToken + single_use: true + token_parameter_name: '%env(COOKIE_PREFIX)%refresh_token' diff --git a/api/config/packages/lexik_jwt_authentication.yaml b/api/config/packages/lexik_jwt_authentication.yaml index 9c41d2d8c3..f2e3b4e20c 100644 --- a/api/config/packages/lexik_jwt_authentication.yaml +++ b/api/config/packages/lexik_jwt_authentication.yaml @@ -7,9 +7,7 @@ lexik_jwt_authentication: public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' - # Tokens are valid for 12 hours, should be safe because we never expose the whole token to JavaScript. - # Of course it would be even better to have only short-lived tokens but renew them on every request. - token_ttl: 43200 + token_ttl: '%env(AUTHENTICATION_TOKEN_TTL)%' # Read the JWT token from a split cookie: The [api-domain]_jwt_hp and [api-domain]_jwt_s cookies are combined with a period (.) # to form the full JWT token. diff --git a/api/config/packages/security.yaml b/api/config/packages/security.yaml index 5e9a060ede..6a6148083d 100644 --- a/api/config/packages/security.yaml +++ b/api/config/packages/security.yaml @@ -28,6 +28,7 @@ security: lazy: true provider: app_user_provider user_checker: App\Security\UserStatusChecker + entry_point: jwt json_login: check_path: /authentication_token username_path: identifier @@ -35,6 +36,8 @@ security: success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure jwt: ~ + refresh_jwt: + check_path: /token/refresh custom_authenticators: - App\Security\OAuth\GoogleAuthenticator - App\Security\OAuth\HitobitoAuthenticator @@ -46,6 +49,7 @@ security: - { path: ^/auth, roles: PUBLIC_ACCESS } # OAuth and resend password endpoints - { path: ^/content_types, roles: PUBLIC_ACCESS } # Content types is more or less static and the same for all camps - { path: ^/invitations/.*/(find|reject), roles: PUBLIC_ACCESS } + - { path: ^/token/refresh, roles: PUBLIC_ACCESS } - { path: ^/users$, methods: [POST], roles: PUBLIC_ACCESS } # register - { path: ^/users/.*/activate$, methods: [PATCH], roles: PUBLIC_ACCESS } - { path: .*, roles: [ROLE_USER] } # Protect all other routes must be at the end diff --git a/api/config/routes.yaml b/api/config/routes.yaml index 7169860d33..73fbaa045b 100644 --- a/api/config/routes.yaml +++ b/api/config/routes.yaml @@ -4,3 +4,6 @@ authentication_token: path: /authentication_token methods: ['POST'] +api_refresh_token: + path: /token/refresh + methods: ['POST'] diff --git a/api/config/services.yaml b/api/config/services.yaml index d8ce8c5382..ac8a9ca8cc 100644 --- a/api/config/services.yaml +++ b/api/config/services.yaml @@ -103,6 +103,12 @@ services: App\OpenApi\OAuthDecorator: decorates: 'api_platform.openapi.factory' + App\OpenApi\RefreshTokenDecorator: + decorates: 'api_platform.openapi.factory' + arguments: + - '@.inner' + - '%env(COOKIE_PREFIX)%' + App\OAuth\UrlGeneratorDecorator: class: App\OAuth\UrlGeneratorDecorator arguments: diff --git a/api/migrations/schema/Version20250809140557.php b/api/migrations/schema/Version20250809140557.php new file mode 100644 index 0000000000..aaa3db8ce2 --- /dev/null +++ b/api/migrations/schema/Version20250809140557.php @@ -0,0 +1,26 @@ +addSql('CREATE TABLE refresh_tokens (refresh_token VARCHAR(128) NOT NULL, username VARCHAR(255) NOT NULL, valid TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_9BACE7E1C74F2195 ON refresh_tokens (refresh_token)'); + } + + public function down(Schema $schema): void { + $this->addSql('DROP TABLE refresh_tokens'); + } +} diff --git a/api/src/Entity/RefreshToken.php b/api/src/Entity/RefreshToken.php new file mode 100644 index 0000000000..91810545d6 --- /dev/null +++ b/api/src/Entity/RefreshToken.php @@ -0,0 +1,10 @@ + [ - 'description' => "Get a JWT token split across the two cookies {$cookiePrefix}jwt_hp and {$cookiePrefix}jwt_s", + 'description' => "Get a JWT token split across the two cookies {$cookiePrefix}jwt_hp and {$cookiePrefix}jwt_s. Also returns a refresh token in {$cookiePrefix}refresh_token", ], ], summary: 'Log in using email and password.', diff --git a/api/src/OpenApi/RefreshTokenDecorator.php b/api/src/OpenApi/RefreshTokenDecorator.php new file mode 100644 index 0000000000..d65e24416b --- /dev/null +++ b/api/src/OpenApi/RefreshTokenDecorator.php @@ -0,0 +1,33 @@ +decorated)($context); + + $cookiePrefix = $this->cookiePrefix; + $pathItem = new Model\PathItem( + ref: 'JWT Token Refresh', + post: new Model\Operation( + operationId: 'postRefreshToken', + tags: ['JWT Refresh'], + responses: [ + '204' => [ + 'description' => "Get a refreshed JWT token split across the two cookies {$cookiePrefix}jwt_hp and {$cookiePrefix}jwt_s. Also returns a new refresh token {$cookiePrefix}refresh_token.", + ], + ], + summary: 'Refresh token.', + ), + ); + $openApi->getPaths()->addPath('/token/refresh', $pathItem); + + return $openApi; + } +} diff --git a/api/src/Serializer/Normalizer/UriTemplateNormalizer.php b/api/src/Serializer/Normalizer/UriTemplateNormalizer.php index 52bbfa5110..0790886f28 100644 --- a/api/src/Serializer/Normalizer/UriTemplateNormalizer.php +++ b/api/src/Serializer/Normalizer/UriTemplateNormalizer.php @@ -51,6 +51,7 @@ public function normalize($data, $format = null, array $context = []): array|\Ar $result['_links']['oauthPbsmidata'] = ['href' => $this->urlGenerator->generate('connect_pbsmidata_start').'{?callback}', 'templated' => true]; $result['_links']['oauthCevidb'] = ['href' => $this->urlGenerator->generate('connect_cevidb_start').'{?callback}', 'templated' => true]; $result['_links']['oauthJubladb'] = ['href' => $this->urlGenerator->generate('connect_jubladb_start').'{?callback}', 'templated' => true]; + $result['_links']['refreshToken'] = ['href' => $this->urlGenerator->generate('api_refresh_token')]; $result['_links']['resetPassword'] = ['href' => $this->urlGenerator->generate('_api_/auth/reset_password{._format}_post').'{/id}', 'templated' => true]; $result['_links']['resendActivation'] = ['href' => $this->urlGenerator->generate('_api_/auth/resend_activation{._format}_post'), 'templated' => false]; diff --git a/api/symfony.lock b/api/symfony.lock index d8d349716f..4deb9870bf 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -174,6 +174,20 @@ "gedmo/doctrine-extensions": { "version": "v3.0.5" }, + "gesdinet/jwt-refresh-token-bundle": { + "version": "1.5", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.0", + "ref": "2390b4ed5c195e0b3f6dea45221f3b7c0af523a0" + }, + "files": [ + "config/packages/gesdinet_jwt_refresh_token.yaml", + "config/routes/gesdinet_jwt_refresh_token.yaml", + "src/Entity/RefreshToken.php" + ] + }, "google/recaptcha": { "version": "1.2", "recipe": { diff --git a/api/tests/Api/FirewallTest.php b/api/tests/Api/FirewallTest.php index 9677862463..360fd16a86 100644 --- a/api/tests/Api/FirewallTest.php +++ b/api/tests/Api/FirewallTest.php @@ -116,6 +116,7 @@ private static function isProtectedByFirewall(mixed $endpoint): bool { '/auth/resend_activation' => false, '/content_types' => false, '/invitations' => false, + '/token/refresh' => false, default => true }; } diff --git a/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php b/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php index 60a2de7e8a..346c626aeb 100644 --- a/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php +++ b/api/tests/Api/SnapshotTests/EndpointPerformanceTest.php @@ -244,6 +244,7 @@ private static function getCollectionEndpoints() { '/auth/resend_activation' => false, '/invitations' => false, '/personal_invitations' => false, + '/token/refresh' => false, default => true }; }); diff --git a/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php b/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php index 0eb1a4c37e..388a56d2f3 100644 --- a/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php +++ b/api/tests/Api/SnapshotTests/ResponseSnapshotTest.php @@ -117,6 +117,7 @@ public static function getCollectionEndpoints() { '/auth/resend_activation' => false, '/invitations' => false, '/personal_invitations' => false, + '/token/refresh' => false, '/users' => false, default => true }; diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml index 6738d1e845..4e66532170 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml @@ -34692,6 +34692,16 @@ paths: summary: 'Updates the ScheduleEntry resource.' tags: - ScheduleEntry + /token/refresh: + post: + operationId: postRefreshToken + responses: + 204: + description: 'Get a refreshed JWT token split across the two cookies example_com_jwt_hp and example_com_jwt_s. Also returns a new refresh token example_com_refresh_token.' + summary: 'Refresh token.' + tags: + - 'JWT Refresh' + ref: 'JWT Token Refresh' /users: get: deprecated: false diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json index 0297f21a1b..5e13e3838c 100644 --- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json +++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testRootEndpointMatchesSnapshot__1.json @@ -111,6 +111,9 @@ "href": "/profiles{/id}{?user.collaborations.camp,user.collaborations.camp[],user,user[]}", "templated": true }, + "refreshToken": { + "href": "/token/refresh" + }, "resendActivation": { "href": "/auth/resend_activation", "templated": false diff --git a/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php b/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php index a56114b449..e788a5410d 100644 --- a/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php +++ b/api/tests/Serializer/Normalizer/UriTemplateNormalizerTest.php @@ -45,6 +45,9 @@ protected function setUp(): void { case 'connect_jubladb_start': return '/auth/jubladb'; + case 'api_refresh_token': + return '/token/refresh'; + case '_api_/auth/resend_activation{._format}_post': return '/auth/resend_activation'; @@ -91,6 +94,9 @@ protected function setUp(): void { 'href' => '/auth/reset_password{/id}', 'templated' => true, ], + 'refreshToken' => [ + 'href' => '/token/refresh', + ], ]; } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f0f57a09ce..1677c7d7de 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -31,6 +31,7 @@ export default { window.addEventListener('offline', this.offlineListener) window.addEventListener('online', this.onlineListener) + window.addEventListener('visibilitychange', this.visibilityChangeListener) }, async mounted() { if (this.$auth.isLoggedIn()) { @@ -50,8 +51,15 @@ export default { offlineListener() { this.offline = true }, - onlineListener() { + async onlineListener() { this.offline = false + await this.$auth.initRefresh() + }, + async visibilityChangeListener() { + if (document.visibilityState !== 'visible') { + return + } + await this.$auth.initRefresh() }, }, } diff --git a/frontend/src/main.js b/frontend/src/main.js index 5714f00f51..6f98317b3b 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -23,6 +23,7 @@ import 'vue-toastification/dist/index.css' import { ClickOutside, Resize } from 'vuetify/lib/directives' import ResizeObserver from 'v-resize-observer' +import { initRefresh } from '@/plugins/auth.js' const env = getEnv() if (env && env.SENTRY_FRONTEND_DSN) { @@ -63,3 +64,6 @@ new Vue({ unhead, render: (h) => h(App), }).$mount('#app') + +// noinspection JSIgnoredPromiseFromCall +initRefresh() diff --git a/frontend/src/plugins/auth.js b/frontend/src/plugins/auth.js index 0c93a31945..71129888a4 100644 --- a/frontend/src/plugins/auth.js +++ b/frontend/src/plugins/auth.js @@ -1,5 +1,6 @@ import axios from 'axios' import { apiStore, store } from '@/plugins/store' +import { hasLoggedOutFromLocalStorage } from '@/plugins/store/auth.js' import router from '@/router' import Cookies from 'js-cookie' import { getEnv } from '@/environment.js' @@ -11,6 +12,54 @@ axios.interceptors.response.use(null, (error) => { return Promise.reject(error) }) +let scheduledRefresh = null + +export async function initRefresh() { + // Cookies.get was not reliable to detect if the cookie was present. + if (hasLoggedOutFromLocalStorage()) { + return + } + let originalTarget = `${window.location.pathname}` + if (window.location.search) { + originalTarget += `?${window.location.search}` + } + let refreshedSuccessfully = false + if (!isLoggedIn()) { + try { + await refresh() + } catch { + /* empty */ + } + if (!isLoggedIn()) { + return + } + refreshedSuccessfully = true + } + rescheduleRefresh() + if (refreshedSuccessfully) { + await router.replace(originalTarget) + } +} + +function rescheduleRefresh() { + if (scheduledRefresh != null) { + clearTimeout(scheduledRefresh) + } + const timeout = (getJWTExpirationTimestamp() - Date.now()) / 2 + const realTimeout = Math.max(Math.min(timeout, 30 * 60 * 1000), 2 * 60 * 1000) + scheduledRefresh = setTimeout(refreshAndSchedule, realTimeout) +} + +async function refreshAndSchedule() { + await refresh() + rescheduleRefresh() +} + +async function refresh() { + const url = await apiStore.href(apiStore.get(), 'refreshToken') + return apiStore.post(url) +} + function getJWTPayloadFromCookie() { const jwtHeaderAndPayload = Cookies.get(headerAndPayloadCookieName()) if (!jwtHeaderAndPayload) return '' @@ -58,6 +107,7 @@ export function isAdmin() { async function login(email, password) { const url = await apiStore.href(apiStore.get(), 'login') return apiStore.post(url, { identifier: email, password: password }).then(() => { + rescheduleRefresh() return isLoggedIn() }) } @@ -141,6 +191,9 @@ async function loginJublaDB() { } export async function logout() { + if (scheduledRefresh != null) { + clearTimeout(scheduledRefresh) + } Cookies.remove(headerAndPayloadCookieName()) store.commit('logout') return router @@ -159,6 +212,7 @@ function cookiePrefix() { } export const auth = { + initRefresh, isLoggedIn, isAdmin, login, diff --git a/frontend/src/plugins/store/auth.js b/frontend/src/plugins/store/auth.js index 560c2c8e0c..7608f81398 100644 --- a/frontend/src/plugins/store/auth.js +++ b/frontend/src/plugins/store/auth.js @@ -1,5 +1,12 @@ import { apiStore } from '@/plugins/store/index' +/** + * Because we cannot differentiate between a expired cookie and a deleted cookie, + * we use localStorage to track if a user has logged out and does not want + * to refresh the access token. + */ +const HAS_LOGGED_OUT = 'hasLoggedOut' + export const state = { user: null, } @@ -7,10 +14,12 @@ export const state = { export const mutations = { login(state, user) { state.user = user + window.localStorage.setItem(HAS_LOGGED_OUT, 'false') }, logout(state) { state.user = null + window.localStorage.setItem(HAS_LOGGED_OUT, 'true') }, } export const getters = { @@ -23,6 +32,10 @@ export const getters = { }, } +export function hasLoggedOutFromLocalStorage() { + return window.localStorage.getItem(HAS_LOGGED_OUT) === 'true' +} + export default { state, mutations,