kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Use scoped tokens to load <audio> urls instead of JWT
rodzic
13d28f7b0c
commit
ec8dfdb740
|
@ -609,6 +609,8 @@ OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "users.AccessToken"
|
|||
OAUTH2_PROVIDER_GRANT_MODEL = "users.Grant"
|
||||
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "users.RefreshToken"
|
||||
|
||||
SCOPED_TOKENS_MAX_AGE = 60 * 60 * 24 * 3
|
||||
|
||||
# LDAP AUTHENTICATION CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False)
|
||||
|
|
|
@ -29,6 +29,7 @@ class TokenAuthMiddleware:
|
|||
self.inner = inner
|
||||
|
||||
def __call__(self, scope):
|
||||
# XXX: 1.0 remove this, replace with websocket/scopedtoken
|
||||
auth = TokenHeaderAuth()
|
||||
try:
|
||||
user, token = auth.authenticate(scope)
|
||||
|
|
|
@ -30,6 +30,7 @@ from funkwhale_api.federation import tasks as federation_tasks
|
|||
from funkwhale_api.tags.models import Tag, TaggedItem
|
||||
from funkwhale_api.tags.serializers import TagSerializer
|
||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||
from funkwhale_api.users.authentication import ScopedTokenAuthentication
|
||||
|
||||
from . import filters, licenses, models, serializers, tasks, utils
|
||||
|
||||
|
@ -571,7 +572,7 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
|||
serializer_class = serializers.TrackSerializer
|
||||
authentication_classes = (
|
||||
rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
|
||||
+ [SignatureAuthentication]
|
||||
+ [SignatureAuthentication, ScopedTokenAuthentication]
|
||||
)
|
||||
permission_classes = [oauth_permissions.ScopePermission]
|
||||
required_scope = "libraries"
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
from django.conf import settings
|
||||
from django.core import signing
|
||||
|
||||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .oauth import scopes as available_scopes
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def generate_scoped_token(user_id, user_secret, scopes):
|
||||
if set(scopes) & set(available_scopes.SCOPES_BY_ID) != set(scopes):
|
||||
raise ValueError("{} contains invalid scopes".format(scopes))
|
||||
|
||||
return signing.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"user_secret": str(user_secret),
|
||||
"scopes": list(sorted(scopes)),
|
||||
},
|
||||
salt="scoped_tokens",
|
||||
)
|
||||
|
||||
|
||||
def authenticate_scoped_token(token):
|
||||
try:
|
||||
payload = signing.loads(
|
||||
token, salt="scoped_tokens", max_age=settings.SCOPED_TOKENS_MAX_AGE,
|
||||
)
|
||||
except signing.BadSignature:
|
||||
raise exceptions.AuthenticationFailed("Invalid token signature")
|
||||
|
||||
try:
|
||||
user_id = int(payload["user_id"])
|
||||
user_secret = str(payload["user_secret"])
|
||||
scopes = list(payload["scopes"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
raise exceptions.AuthenticationFailed("Invalid scoped token payload")
|
||||
|
||||
try:
|
||||
user = (
|
||||
models.User.objects.all()
|
||||
.for_auth()
|
||||
.get(pk=user_id, secret_key=user_secret, is_active=True)
|
||||
)
|
||||
except (models.User.DoesNotExist, ValidationError):
|
||||
raise exceptions.AuthenticationFailed("Invalid user")
|
||||
|
||||
return user, scopes
|
||||
|
||||
|
||||
class ScopedTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""
|
||||
Used when signed token returned by generate_scoped_token are provided via
|
||||
token= in GET requests. Mostly for <audio src=""> urls, since it's not possible
|
||||
to override headers sent by the browser when loading media.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
data = request.GET
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
user, scopes = authenticate_scoped_token(token)
|
||||
except exceptions.AuthenticationFailed:
|
||||
raise exceptions.AuthenticationFailed("Invalid token")
|
||||
|
||||
setattr(request, "scopes", scopes)
|
||||
setattr(request, "actor", user.actor)
|
||||
return user, None
|
|
@ -77,6 +77,10 @@ class ScopePermission(permissions.BasePermission):
|
|||
|
||||
if isinstance(token, models.AccessToken):
|
||||
return self.has_permission_token(token, required_scope)
|
||||
elif getattr(request, "scopes", None):
|
||||
return should_allow(
|
||||
required_scope=required_scope, request_scopes=set(request.scopes)
|
||||
)
|
||||
elif request.user.is_authenticated:
|
||||
user_scopes = scopes.get_from_permissions(**request.user.get_permissions())
|
||||
return should_allow(
|
||||
|
|
|
@ -22,6 +22,7 @@ from funkwhale_api.moderation import utils as moderation_utils
|
|||
|
||||
from . import adapters
|
||||
from . import models
|
||||
from . import authentication as users_authentication
|
||||
|
||||
|
||||
@deconstructible
|
||||
|
@ -220,6 +221,7 @@ class UserReadSerializer(serializers.ModelSerializer):
|
|||
class MeSerializer(UserReadSerializer):
|
||||
quota_status = serializers.SerializerMethodField()
|
||||
summary = serializers.SerializerMethodField()
|
||||
tokens = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(UserReadSerializer.Meta):
|
||||
fields = UserReadSerializer.Meta.fields + [
|
||||
|
@ -227,6 +229,7 @@ class MeSerializer(UserReadSerializer):
|
|||
"instance_support_message_display_date",
|
||||
"funkwhale_support_message_display_date",
|
||||
"summary",
|
||||
"tokens",
|
||||
]
|
||||
|
||||
def get_quota_status(self, o):
|
||||
|
@ -237,6 +240,13 @@ class MeSerializer(UserReadSerializer):
|
|||
return
|
||||
return common_serializers.ContentSerializer(o.actor.summary_obj).data
|
||||
|
||||
def get_tokens(self, o):
|
||||
return {
|
||||
"listen": users_authentication.generate_scoped_token(
|
||||
user_id=o.pk, user_secret=o.secret_key, scopes=["read:libraries"]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class PasswordResetSerializer(PRS):
|
||||
def get_email_options(self):
|
||||
|
|
|
@ -15,6 +15,7 @@ from funkwhale_api.federation import api_serializers as federation_api_serialize
|
|||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.music import licenses, models, serializers, tasks, views
|
||||
from funkwhale_api.users import authentication as users_authentication
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
@ -1488,3 +1489,15 @@ def test_other_user_cannot_delete_track(factories, logged_in_api_client):
|
|||
|
||||
assert response.status_code == 404
|
||||
track.refresh_from_db()
|
||||
|
||||
|
||||
def test_listen_to_track_with_scoped_token(factories, api_client):
|
||||
user = factories["users.User"]()
|
||||
token = users_authentication.generate_scoped_token(
|
||||
user_id=user.pk, user_secret=user.secret_key, scopes=["read:libraries"]
|
||||
)
|
||||
upload = factories["music.Upload"](playable=True)
|
||||
url = reverse("api:v1:listen-detail", kwargs={"uuid": upload.track.uuid})
|
||||
response = api_client.get(url, {"token": token})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
|
|
@ -62,7 +62,7 @@ def test_scope_permission_anonymous_policy(
|
|||
view = mocker.Mock(
|
||||
required_scope="libraries", anonymous_policy=policy, anonymous_scopes=set()
|
||||
)
|
||||
request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
|
||||
request = mocker.Mock(method="GET", user=anonymous_user, actor=None, scopes=None)
|
||||
|
||||
p = permissions.ScopePermission()
|
||||
|
||||
|
@ -76,7 +76,7 @@ def test_scope_permission_dict_no_required(mocker, anonymous_user):
|
|||
action="read",
|
||||
anonymous_scopes=set(),
|
||||
)
|
||||
request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
|
||||
request = mocker.Mock(method="GET", user=anonymous_user, actor=None, scopes=None)
|
||||
|
||||
p = permissions.ScopePermission()
|
||||
|
||||
|
@ -97,7 +97,7 @@ def test_scope_permission_user(
|
|||
):
|
||||
user = factories["users.User"]()
|
||||
should_allow = mocker.patch.object(permissions, "should_allow")
|
||||
request = mocker.Mock(method=method, user=user, actor=None)
|
||||
request = mocker.Mock(method=method, user=user, actor=None, scopes=None)
|
||||
view = mocker.Mock(
|
||||
required_scope=required_scope, anonymous_policy=False, action=action
|
||||
)
|
||||
|
@ -131,10 +131,27 @@ def test_scope_permission_token(mocker, factories):
|
|||
)
|
||||
|
||||
|
||||
def test_scope_permission_request_scopes(mocker, factories):
|
||||
should_allow = mocker.patch.object(permissions, "should_allow")
|
||||
request = mocker.Mock(method="POST", scopes=["write:profile", "read:playlists"])
|
||||
view = mocker.Mock(required_scope="profile", anonymous_policy=False)
|
||||
p = permissions.ScopePermission()
|
||||
|
||||
assert p.has_permission(request, view) == should_allow.return_value
|
||||
|
||||
should_allow.assert_called_once_with(
|
||||
required_scope="write:profile",
|
||||
request_scopes={"write:profile", "read:playlists"},
|
||||
)
|
||||
|
||||
|
||||
def test_scope_permission_actor(mocker, factories, anonymous_user):
|
||||
should_allow = mocker.patch.object(permissions, "should_allow")
|
||||
request = mocker.Mock(
|
||||
method="POST", actor=factories["federation.Actor"](), user=anonymous_user
|
||||
method="POST",
|
||||
actor=factories["federation.Actor"](),
|
||||
user=anonymous_user,
|
||||
scopes=None,
|
||||
)
|
||||
view = mocker.Mock(required_scope="profile", anonymous_policy=False)
|
||||
p = permissions.ScopePermission()
|
||||
|
@ -151,7 +168,7 @@ def test_scope_permission_token_anonymous_user_auth_required(
|
|||
):
|
||||
preferences["common__api_authentication_required"] = True
|
||||
should_allow = mocker.patch.object(permissions, "should_allow")
|
||||
request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
|
||||
request = mocker.Mock(method="POST", user=anonymous_user, actor=None, scopes=None)
|
||||
view = mocker.Mock(required_scope="profile", anonymous_policy=False)
|
||||
|
||||
p = permissions.ScopePermission()
|
||||
|
@ -166,7 +183,7 @@ def test_scope_permission_token_anonymous_user_auth_not_required(
|
|||
):
|
||||
preferences["common__api_authentication_required"] = False
|
||||
should_allow = mocker.patch.object(permissions, "should_allow")
|
||||
request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
|
||||
request = mocker.Mock(method="POST", user=anonymous_user, actor=None, scopes=None)
|
||||
view = mocker.Mock(
|
||||
required_scope="profile", anonymous_policy="setting", anonymous_scopes=set()
|
||||
)
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
import pytest
|
||||
|
||||
from django.core import signing
|
||||
|
||||
from funkwhale_api.users import authentication
|
||||
|
||||
|
||||
def test_generate_scoped_token(mocker):
|
||||
dumps = mocker.patch("django.core.signing.dumps")
|
||||
|
||||
result = authentication.generate_scoped_token(
|
||||
user_id=42, user_secret="hello", scopes=["read"],
|
||||
)
|
||||
|
||||
assert result == dumps.return_value
|
||||
dumps.assert_called_once_with(
|
||||
{"scopes": ["read"], "user_secret": "hello", "user_id": 42},
|
||||
salt="scoped_tokens",
|
||||
)
|
||||
|
||||
|
||||
def test_authenticate_scoped_token(mocker, factories, settings):
|
||||
loads = mocker.spy(signing, "loads")
|
||||
user = factories["users.User"]()
|
||||
token = signing.dumps(
|
||||
{"user_id": user.pk, "user_secret": str(user.secret_key), "scopes": ["read"]},
|
||||
salt="scoped_tokens",
|
||||
)
|
||||
|
||||
logged_user, scopes = authentication.authenticate_scoped_token(token)
|
||||
|
||||
assert scopes == ["read"]
|
||||
assert logged_user == user
|
||||
loads.assert_called_once_with(
|
||||
token, salt="scoped_tokens", max_age=settings.SCOPED_TOKENS_MAX_AGE
|
||||
)
|
||||
|
||||
|
||||
def test_authenticate_scoped_token_bad_signature():
|
||||
with pytest.raises(authentication.exceptions.AuthenticationFailed):
|
||||
authentication.authenticate_scoped_token("hello")
|
||||
|
||||
|
||||
def test_authenticate_scoped_token_bad_secret_key(factories):
|
||||
user = factories["users.User"]()
|
||||
token = authentication.generate_scoped_token(
|
||||
user_id=user.pk, user_secret="invalid", scopes=["read"]
|
||||
)
|
||||
|
||||
with pytest.raises(authentication.exceptions.AuthenticationFailed):
|
||||
authentication.authenticate_scoped_token(token)
|
||||
|
||||
|
||||
def test_scope_token_authentication(fake_request, factories, mocker):
|
||||
user = factories["users.User"]()
|
||||
actor = user.create_actor()
|
||||
authenticate_scoped_token = mocker.spy(authentication, "authenticate_scoped_token")
|
||||
token = authentication.generate_scoped_token(
|
||||
user_id=user.pk, user_secret=user.secret_key, scopes=["read"]
|
||||
)
|
||||
request = fake_request.get("/", {"token": token})
|
||||
auth = authentication.ScopedTokenAuthentication()
|
||||
|
||||
assert auth.authenticate(request) == (user, None)
|
||||
assert request.scopes == ["read"]
|
||||
assert request.actor == actor
|
||||
authenticate_scoped_token.assert_called_once_with(token)
|
||||
|
||||
|
||||
def test_scope_token_invalid(fake_request, factories):
|
||||
token = "test"
|
||||
request = fake_request.get("/", {"token": token})
|
||||
auth = authentication.ScopedTokenAuthentication()
|
||||
|
||||
with pytest.raises(authentication.exceptions.AuthenticationFailed):
|
||||
auth.authenticate(request)
|
||||
|
||||
|
||||
def test_scope_token_missing(fake_request, factories):
|
||||
request = fake_request.get("/")
|
||||
auth = authentication.ScopedTokenAuthentication()
|
||||
|
||||
assert auth.authenticate(request) is None
|
|
@ -42,3 +42,18 @@ def test_registration_serializer_validates_password_properly(data, expected_erro
|
|||
|
||||
with pytest.raises(serializers.serializers.ValidationError, match=expected_error):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
def test_me_serializer_includes_tokens(factories, mocker):
|
||||
user = factories["users.User"]()
|
||||
generate_scoped_token = mocker.patch(
|
||||
"funkwhale_api.users.authentication.generate_scoped_token"
|
||||
)
|
||||
expected = {"listen": generate_scoped_token.return_value}
|
||||
serializer = serializers.MeSerializer(user)
|
||||
|
||||
assert serializer.data["tokens"] == expected
|
||||
|
||||
generate_scoped_token.assert_called_once_with(
|
||||
user_id=user.pk, user_secret=user.secret_key, scopes=["read:libraries"]
|
||||
)
|
||||
|
|
|
@ -20,7 +20,6 @@
|
|||
"fomantic-ui-css": "^2.8.3",
|
||||
"howler": "^2.0.14",
|
||||
"js-logger": "^1.4.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.10",
|
||||
"moment": "^2.22.2",
|
||||
"qs": "^6.7.0",
|
||||
|
|
|
@ -114,6 +114,10 @@ export default {
|
|||
}
|
||||
await this.fetchNodeInfo()
|
||||
this.$store.dispatch('auth/check')
|
||||
setInterval(() => {
|
||||
// used to refresh profile every now and then (important for refreshing scoped tokens)
|
||||
self.$store.dispatch('auth/check')
|
||||
}, 1000 * 60 * 60 * 8)
|
||||
this.$store.dispatch('instance/fetchSettings')
|
||||
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
|
|
|
@ -428,8 +428,17 @@ export default {
|
|||
// so authentication can be checked by the backend
|
||||
// because for audio files we cannot use the regular Authentication
|
||||
// header
|
||||
let param = "jwt"
|
||||
let value = this.$store.state.auth.token
|
||||
if (this.$store.state.auth.scopedTokens && this.$store.state.auth.scopedTokens.listen) {
|
||||
// used scoped tokens instead of JWT to reduce the attack surface if the token
|
||||
// is leaked
|
||||
param = "token"
|
||||
value = this.$store.state.auth.scopedTokens.listen
|
||||
}
|
||||
console.log('HELLO', param, value, this.$store.state.auth.scopedTokens)
|
||||
sources.forEach(e => {
|
||||
e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token)
|
||||
e.url = url.updateQueryString(e.url, param, value)
|
||||
})
|
||||
}
|
||||
return sources
|
||||
|
|
|
@ -229,10 +229,18 @@ export default {
|
|||
this.upload.listen_url
|
||||
)
|
||||
if (this.$store.state.auth.authenticated) {
|
||||
let param = "jwt"
|
||||
let value = this.$store.state.auth.token
|
||||
if (this.$store.state.auth.scopedTokens && this.$store.state.auth.scopedTokens.listen) {
|
||||
// used scoped tokens instead of JWT to reduce the attack surface if the token
|
||||
// is leaked
|
||||
param = "token"
|
||||
value = this.$store.state.auth.scopedTokens.listen
|
||||
}
|
||||
u = url.updateQueryString(
|
||||
u,
|
||||
"jwt",
|
||||
encodeURI(this.$store.state.auth.token)
|
||||
param,
|
||||
encodeURI(value)
|
||||
)
|
||||
}
|
||||
return u
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import Vue from 'vue'
|
||||
import axios from 'axios'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import logger from '@/logging'
|
||||
import router from '@/router'
|
||||
import lodash from '@/lodash'
|
||||
|
||||
function getDefaultScopedTokens () {
|
||||
return {
|
||||
listen: null,
|
||||
}
|
||||
}
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
|
@ -18,7 +22,7 @@ export default {
|
|||
},
|
||||
profile: null,
|
||||
token: '',
|
||||
tokenData: {}
|
||||
scopedTokens: getDefaultScopedTokens()
|
||||
},
|
||||
getters: {
|
||||
header: state => {
|
||||
|
@ -34,7 +38,7 @@ export default {
|
|||
state.username = ''
|
||||
state.fullUsername = ''
|
||||
state.token = ''
|
||||
state.tokenData = {}
|
||||
state.scopedTokens = getDefaultScopedTokens()
|
||||
state.availablePermissions = {
|
||||
federation: false,
|
||||
settings: false,
|
||||
|
@ -51,8 +55,8 @@ export default {
|
|||
state.username = null
|
||||
state.fullUsername = null
|
||||
state.token = null
|
||||
state.tokenData = null
|
||||
state.profile = null
|
||||
state.scopedTokens = getDefaultScopedTokens()
|
||||
state.availablePermissions = {}
|
||||
}
|
||||
},
|
||||
|
@ -69,11 +73,9 @@ export default {
|
|||
},
|
||||
token: (state, value) => {
|
||||
state.token = value
|
||||
if (value) {
|
||||
state.tokenData = jwtDecode(value)
|
||||
} else {
|
||||
state.tokenData = {}
|
||||
}
|
||||
},
|
||||
scopedTokens: (state, value) => {
|
||||
state.scopedTokens = {...value}
|
||||
},
|
||||
permission: (state, {key, status}) => {
|
||||
state.availablePermissions[key] = status
|
||||
|
@ -159,6 +161,9 @@ export default {
|
|||
commit("profile", data)
|
||||
commit("username", data.username)
|
||||
commit("fullUsername", data.full_username)
|
||||
if (data.tokens) {
|
||||
commit("scopedTokens", data.tokens)
|
||||
}
|
||||
Object.keys(data.permissions).forEach(function(key) {
|
||||
// this makes it easier to check for permissions in templates
|
||||
commit("permission", {
|
||||
|
|
|
@ -38,7 +38,6 @@ describe('store/auth', () => {
|
|||
const state = {
|
||||
username: 'dummy',
|
||||
token: 'dummy',
|
||||
tokenData: 'dummy',
|
||||
profile: 'dummy',
|
||||
availablePermissions: 'dummy'
|
||||
}
|
||||
|
@ -46,7 +45,6 @@ describe('store/auth', () => {
|
|||
expect(state.authenticated).to.equal(false)
|
||||
expect(state.username).to.equal(null)
|
||||
expect(state.token).to.equal(null)
|
||||
expect(state.tokenData).to.equal(null)
|
||||
expect(state.profile).to.equal(null)
|
||||
expect(state.availablePermissions).to.deep.equal({})
|
||||
})
|
||||
|
@ -54,24 +52,12 @@ describe('store/auth', () => {
|
|||
const state = {}
|
||||
store.mutations.token(state, null)
|
||||
expect(state.token).to.equal(null)
|
||||
expect(state.tokenData).to.deep.equal({})
|
||||
})
|
||||
it('token real', () => {
|
||||
// generated on http://kjur.github.io/jsjws/tool_jwt.html
|
||||
const state = {}
|
||||
let token = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2p3dC1pZHAuZXhhbXBsZS5jb20iLCJzdWIiOiJtYWlsdG86bWlrZUBleGFtcGxlLmNvbSIsIm5iZiI6MTUxNTUzMzQyOSwiZXhwIjoxNTE1NTM3MDI5LCJpYXQiOjE1MTU1MzM0MjksImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9.'
|
||||
let tokenData = {
|
||||
iss: 'https://jwt-idp.example.com',
|
||||
sub: 'mailto:mike@example.com',
|
||||
nbf: 1515533429,
|
||||
exp: 1515537029,
|
||||
iat: 1515533429,
|
||||
jti: 'id123456',
|
||||
typ: 'https://example.com/register'
|
||||
}
|
||||
store.mutations.token(state, token)
|
||||
expect(state.token).to.equal(token)
|
||||
expect(state.tokenData).to.deep.equal(tokenData)
|
||||
})
|
||||
it('permissions', () => {
|
||||
const state = { availablePermissions: {} }
|
||||
|
|
|
@ -5839,11 +5839,6 @@ just-extend@^4.0.2:
|
|||
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc"
|
||||
integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==
|
||||
|
||||
jwt-decode@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
|
||||
integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
|
||||
|
||||
killable@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
|
||||
|
|
Ładowanie…
Reference in New Issue