From 4c13d47387a5b8d700917ff1c8d59e3cb5ced957 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Mon, 25 Mar 2019 17:02:51 +0100
Subject: [PATCH] Resolve "Implement a Oauth provider in Funkwhale"

---
 api/config/api_urls.py                        |   4 +
 api/config/settings/common.py                 |  42 +-
 api/funkwhale_api/common/decorators.py        |   4 +-
 api/funkwhale_api/favorites/views.py          |   7 +-
 api/funkwhale_api/federation/api_views.py     |  11 +-
 api/funkwhale_api/history/views.py            |   9 +-
 api/funkwhale_api/instance/views.py           |   6 +-
 api/funkwhale_api/manage/views.py             |  19 +-
 api/funkwhale_api/moderation/views.py         |   3 +-
 api/funkwhale_api/music/views.py              |  42 +-
 api/funkwhale_api/playlists/views.py          |  13 +-
 api/funkwhale_api/radios/views.py             |   4 +-
 api/funkwhale_api/subsonic/views.py           |   4 +-
 api/funkwhale_api/users/factories.py          |  48 ++-
 .../users/migrations/0014_oauth.py            | 195 ++++++++++
 .../migrations/0015_application_scope.py      |  18 +
 api/funkwhale_api/users/models.py             |  68 +++-
 api/funkwhale_api/users/oauth/__init__.py     |   0
 api/funkwhale_api/users/oauth/permissions.py  | 123 ++++++
 api/funkwhale_api/users/oauth/scopes.py       |  93 +++++
 api/funkwhale_api/users/oauth/serializers.py  |  29 ++
 api/funkwhale_api/users/oauth/tasks.py        |   8 +
 api/funkwhale_api/users/oauth/urls.py         |  16 +
 api/funkwhale_api/users/oauth/views.py        | 182 +++++++++
 api/funkwhale_api/users/permissions.py        |  23 --
 api/funkwhale_api/users/views.py              |   9 +-
 api/requirements/base.txt                     |   2 +
 api/tests/conftest.py                         |  11 -
 api/tests/favorites/test_favorites.py         |  44 ++-
 api/tests/instance/test_views.py              |   8 -
 api/tests/manage/test_views.py                |  17 +-
 api/tests/users/oauth/__init__.py             |   0
 api/tests/users/oauth/test_api_permissions.py |  79 ++++
 api/tests/users/oauth/test_models.py          |  21 +
 api/tests/users/oauth/test_permissions.py     | 241 ++++++++++++
 api/tests/users/oauth/test_scopes.py          | 156 ++++++++
 api/tests/users/oauth/test_tasks.py           |  10 +
 api/tests/users/oauth/test_views.py           | 363 ++++++++++++++++++
 api/tests/users/test_permissions.py           |  92 -----
 changes/changelog.d/752.feature               |   1 +
 changes/notes.rst                             |  14 +
 dev.yml                                       |   2 +-
 docs/developers/authentication.rst            |  97 +++++
 docs/developers/index.rst                     |   1 +
 docs/swagger.yml                              | 162 +++++++-
 front/src/components/auth/ApplicationEdit.vue |  80 ++++
 front/src/components/auth/ApplicationForm.vue | 183 +++++++++
 front/src/components/auth/ApplicationNew.vue  |  39 ++
 front/src/components/auth/Authorize.vue       | 201 ++++++++++
 front/src/components/auth/Settings.vue        | 176 ++++++++-
 front/src/components/mixins/Translations.vue  |  42 ++
 front/src/components/semantic/Modal.vue       |   3 +-
 front/src/router/index.js                     |  27 ++
 front/src/style/_main.scss                    |   8 +
 54 files changed, 2811 insertions(+), 249 deletions(-)
 create mode 100644 api/funkwhale_api/users/migrations/0014_oauth.py
 create mode 100644 api/funkwhale_api/users/migrations/0015_application_scope.py
 create mode 100644 api/funkwhale_api/users/oauth/__init__.py
 create mode 100644 api/funkwhale_api/users/oauth/permissions.py
 create mode 100644 api/funkwhale_api/users/oauth/scopes.py
 create mode 100644 api/funkwhale_api/users/oauth/serializers.py
 create mode 100644 api/funkwhale_api/users/oauth/tasks.py
 create mode 100644 api/funkwhale_api/users/oauth/urls.py
 create mode 100644 api/funkwhale_api/users/oauth/views.py
 delete mode 100644 api/funkwhale_api/users/permissions.py
 create mode 100644 api/tests/users/oauth/__init__.py
 create mode 100644 api/tests/users/oauth/test_api_permissions.py
 create mode 100644 api/tests/users/oauth/test_models.py
 create mode 100644 api/tests/users/oauth/test_permissions.py
 create mode 100644 api/tests/users/oauth/test_scopes.py
 create mode 100644 api/tests/users/oauth/test_tasks.py
 create mode 100644 api/tests/users/oauth/test_views.py
 delete mode 100644 api/tests/users/test_permissions.py
 create mode 100644 changes/changelog.d/752.feature
 create mode 100644 docs/developers/authentication.rst
 create mode 100644 front/src/components/auth/ApplicationEdit.vue
 create mode 100644 front/src/components/auth/ApplicationForm.vue
 create mode 100644 front/src/components/auth/ApplicationNew.vue
 create mode 100644 front/src/components/auth/Authorize.vue

diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 93138e9a5d..a40ff3047a 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -75,6 +75,10 @@ v1_patterns += [
         r"^users/",
         include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
     ),
+    url(
+        r"^oauth/",
+        include(("funkwhale_api.users.oauth.urls", "oauth"), namespace="oauth"),
+    ),
     url(r"^token/$", jwt_views.obtain_jwt_token, name="token"),
     url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"),
 ]
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 5f69c36d55..6268d884dd 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -121,6 +121,7 @@ THIRD_PARTY_APPS = (
     "allauth.account",  # registration
     "allauth.socialaccount",  # registration
     "corsheaders",
+    "oauth2_provider",
     "rest_framework",
     "rest_framework.authtoken",
     "taggit",
@@ -152,6 +153,7 @@ LOCAL_APPS = (
     "funkwhale_api.common.apps.CommonConfig",
     "funkwhale_api.activity.apps.ActivityConfig",
     "funkwhale_api.users",  # custom users app
+    "funkwhale_api.users.oauth",
     # Your stuff: custom apps go here
     "funkwhale_api.instance",
     "funkwhale_api.music",
@@ -222,6 +224,14 @@ DATABASES = {
     "default": env.db("DATABASE_URL")
 }
 DATABASES["default"]["ATOMIC_REQUESTS"] = True
+
+MIGRATION_MODULES = {
+    # see https://github.com/jazzband/django-oauth-toolkit/issues/634
+    # swappable models are badly designed in oauth2_provider
+    # ignore migrations and provide our own models.
+    "oauth2_provider": None
+}
+
 #
 # DATABASES = {
 #     'default': {
@@ -343,6 +353,22 @@ AUTH_USER_MODEL = "users.User"
 LOGIN_REDIRECT_URL = "users:redirect"
 LOGIN_URL = "account_login"
 
+# OAuth configuration
+from funkwhale_api.users.oauth import scopes  # noqa
+
+OAUTH2_PROVIDER = {
+    "SCOPES": {s.id: s.label for s in scopes.SCOPES_BY_ID.values()},
+    "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https", "urn"],
+    # we keep expired tokens for 15 days, for tracability
+    "REFRESH_TOKEN_EXPIRE_SECONDS": 3600 * 24 * 15,
+    "AUTHORIZATION_CODE_EXPIRE_SECONDS": 5 * 60,
+    "ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 60 * 10,
+}
+OAUTH2_PROVIDER_APPLICATION_MODEL = "users.Application"
+OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "users.AccessToken"
+OAUTH2_PROVIDER_GRANT_MODEL = "users.Grant"
+OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "users.RefreshToken"
+
 # LDAP AUTHENTICATION CONFIGURATION
 # ------------------------------------------------------------------------------
 AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False)
@@ -450,14 +476,19 @@ CELERY_TASK_TIME_LIMIT = 300
 CELERY_BEAT_SCHEDULE = {
     "federation.clean_music_cache": {
         "task": "federation.clean_music_cache",
-        "schedule": crontab(hour="*/2"),
+        "schedule": crontab(minute="0", hour="*/2"),
         "options": {"expires": 60 * 2},
     },
     "music.clean_transcoding_cache": {
         "task": "music.clean_transcoding_cache",
-        "schedule": crontab(hour="*"),
+        "schedule": crontab(minute="0", hour="*"),
         "options": {"expires": 60 * 2},
     },
+    "oauth.clear_expired_tokens": {
+        "task": "oauth.clear_expired_tokens",
+        "schedule": crontab(minute="0", hour="0"),
+        "options": {"expires": 60 * 60 * 24},
+    },
 }
 
 JWT_AUTH = {
@@ -477,7 +508,6 @@ CORS_ORIGIN_ALLOW_ALL = True
 CORS_ALLOW_CREDENTIALS = True
 
 REST_FRAMEWORK = {
-    "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
     "DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination",
     "PAGE_SIZE": 25,
     "DEFAULT_PARSER_CLASSES": (
@@ -487,12 +517,16 @@ REST_FRAMEWORK = {
         "funkwhale_api.federation.parsers.ActivityParser",
     ),
     "DEFAULT_AUTHENTICATION_CLASSES": (
+        "oauth2_provider.contrib.rest_framework.OAuth2Authentication",
+        "rest_framework.authentication.SessionAuthentication",
         "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
         "funkwhale_api.common.authentication.BearerTokenHeaderAuth",
         "funkwhale_api.common.authentication.JSONWebTokenAuthentication",
-        "rest_framework.authentication.SessionAuthentication",
         "rest_framework.authentication.BasicAuthentication",
     ),
+    "DEFAULT_PERMISSION_CLASSES": (
+        "funkwhale_api.users.oauth.permissions.ScopePermission",
+    ),
     "DEFAULT_FILTER_BACKENDS": (
         "rest_framework.filters.OrderingFilter",
         "django_filters.rest_framework.DjangoFilterBackend",
diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py
index b93f149f0b..49a2fb1939 100644
--- a/api/funkwhale_api/common/decorators.py
+++ b/api/funkwhale_api/common/decorators.py
@@ -87,4 +87,6 @@ def mutations_route(types):
             )
             return response.Response(serializer.data, status=status.HTTP_201_CREATED)
 
-    return decorators.action(methods=["get", "post"], detail=True)(mutations)
+    return decorators.action(
+        methods=["get", "post"], detail=True, required_scope="edits"
+    )(mutations)
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index d54b79cea3..dce285d85c 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -1,6 +1,5 @@
 from rest_framework import mixins, status, viewsets
 from rest_framework.decorators import action
-from rest_framework.permissions import IsAuthenticatedOrReadOnly
 from rest_framework.response import Response
 
 from django.db.models import Prefetch
@@ -9,6 +8,7 @@ from funkwhale_api.activity import record
 from funkwhale_api.common import fields, permissions
 from funkwhale_api.music.models import Track
 from funkwhale_api.music import utils as music_utils
+from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import filters, models, serializers
 
@@ -24,10 +24,11 @@ class TrackFavoriteViewSet(
     serializer_class = serializers.UserTrackFavoriteSerializer
     queryset = models.TrackFavorite.objects.all().select_related("user")
     permission_classes = [
-        permissions.ConditionalAuthentication,
+        oauth_permissions.ScopePermission,
         permissions.OwnerPermission,
-        IsAuthenticatedOrReadOnly,
     ]
+    required_scope = "favorites"
+    anonymous_policy = "setting"
     owner_checks = ["write"]
 
     def get_serializer_class(self):
diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py
index 6b3f30e666..0fe044ec19 100644
--- a/api/funkwhale_api/federation/api_views.py
+++ b/api/funkwhale_api/federation/api_views.py
@@ -5,11 +5,11 @@ from django.db.models import Count
 
 from rest_framework import decorators
 from rest_framework import mixins
-from rest_framework import permissions
 from rest_framework import response
 from rest_framework import viewsets
 
 from funkwhale_api.music import models as music_models
+from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import activity
 from . import api_serializers
@@ -43,7 +43,8 @@ class LibraryFollowViewSet(
         .select_related("actor", "target__actor")
     )
     serializer_class = api_serializers.LibraryFollowSerializer
-    permission_classes = [permissions.IsAuthenticated]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "follows"
     filterset_class = filters.LibraryFollowFilter
     ordering_fields = ("creation_date",)
 
@@ -100,7 +101,8 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
         .annotate(_uploads_count=Count("uploads"))
     )
     serializer_class = api_serializers.LibrarySerializer
-    permission_classes = [permissions.IsAuthenticated]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
 
     def get_queryset(self):
         qs = super().get_queryset()
@@ -169,7 +171,8 @@ class InboxItemViewSet(
         .order_by("-activity__creation_date")
     )
     serializer_class = api_serializers.InboxItemSerializer
-    permission_classes = [permissions.IsAuthenticated]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "notifications"
     filterset_class = filters.InboxItemFilter
     ordering_fields = ("activity__creation_date",)
 
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index b03c85a8ef..30219629a4 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -1,5 +1,4 @@
 from rest_framework import mixins, viewsets
-from rest_framework.permissions import IsAuthenticatedOrReadOnly
 
 from django.db.models import Prefetch
 
@@ -9,6 +8,8 @@ from funkwhale_api.music.models import Track
 from funkwhale_api.music import utils as music_utils
 from . import filters, models, serializers
 
+from funkwhale_api.users.oauth import permissions as oauth_permissions
+
 
 class ListeningViewSet(
     mixins.CreateModelMixin,
@@ -19,11 +20,13 @@ class ListeningViewSet(
 
     serializer_class = serializers.ListeningSerializer
     queryset = models.Listening.objects.all().select_related("user")
+
     permission_classes = [
-        permissions.ConditionalAuthentication,
+        oauth_permissions.ScopePermission,
         permissions.OwnerPermission,
-        IsAuthenticatedOrReadOnly,
     ]
+    required_scope = "listenings"
+    anonymous_policy = "setting"
     owner_checks = ["write"]
     filterset_class = filters.ListeningFilter
 
diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py
index ea63110333..1800c3dbc7 100644
--- a/api/funkwhale_api/instance/views.py
+++ b/api/funkwhale_api/instance/views.py
@@ -5,7 +5,7 @@ from rest_framework import views
 from rest_framework.response import Response
 
 from funkwhale_api.common import preferences
-from funkwhale_api.users.permissions import HasUserPermission
+from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import nodeinfo
 
@@ -14,8 +14,8 @@ NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.so
 
 class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
     pagination_class = None
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["settings"]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "instance:settings"
 
 
 class InstanceSettings(views.APIView):
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index 99d9020315..c4a624e5c8 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -8,7 +8,7 @@ from funkwhale_api.federation import tasks as federation_tasks
 from funkwhale_api.music import models as music_models
 from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.users import models as users_models
-from funkwhale_api.users.permissions import HasUserPermission
+
 
 from . import filters, serializers
 
@@ -23,8 +23,7 @@ class ManageUploadViewSet(
     )
     serializer_class = serializers.ManageUploadSerializer
     filterset_class = filters.ManageUploadFilterSet
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["library"]
+    required_scope = "instance:libraries"
     ordering_fields = [
         "accessed_date",
         "modification_date",
@@ -55,8 +54,7 @@ class ManageUserViewSet(
     queryset = users_models.User.objects.all().order_by("-id")
     serializer_class = serializers.ManageUserSerializer
     filterset_class = filters.ManageUserFilterSet
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["settings"]
+    required_scope = "instance:users"
     ordering_fields = ["date_joined", "last_activity", "username"]
 
     def get_serializer_context(self):
@@ -80,8 +78,7 @@ class ManageInvitationViewSet(
     )
     serializer_class = serializers.ManageInvitationSerializer
     filterset_class = filters.ManageInvitationFilterSet
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["settings"]
+    required_scope = "instance:invitations"
     ordering_fields = ["creation_date", "expiration_date"]
 
     def perform_create(self, serializer):
@@ -114,8 +111,7 @@ class ManageDomainViewSet(
     )
     serializer_class = serializers.ManageDomainSerializer
     filterset_class = filters.ManageDomainFilterSet
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["moderation"]
+    required_scope = "instance:domains"
     ordering_fields = [
         "name",
         "creation_date",
@@ -153,7 +149,7 @@ class ManageActorViewSet(
     )
     serializer_class = serializers.ManageActorSerializer
     filterset_class = filters.ManageActorFilterSet
-    permission_classes = (HasUserPermission,)
+    required_scope = "instance:accounts"
     required_permissions = ["moderation"]
     ordering_fields = [
         "name",
@@ -199,8 +195,7 @@ class ManageInstancePolicyViewSet(
     )
     serializer_class = serializers.ManageInstancePolicySerializer
     filterset_class = filters.ManageInstancePolicyFilterSet
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["moderation"]
+    required_scope = "instance:policies"
     ordering_fields = ["id", "creation_date"]
 
     def perform_create(self, serializer):
diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py
index feeeccf016..4d4e3e039a 100644
--- a/api/funkwhale_api/moderation/views.py
+++ b/api/funkwhale_api/moderation/views.py
@@ -1,7 +1,6 @@
 from django.db import IntegrityError
 
 from rest_framework import mixins
-from rest_framework import permissions
 from rest_framework import response
 from rest_framework import status
 from rest_framework import viewsets
@@ -24,7 +23,7 @@ class UserFilterViewSet(
         .select_related("target_artist")
     )
     serializer_class = serializers.UserFilterSerializer
-    permission_classes = [permissions.IsAuthenticated]
+    required_scope = "filters"
     ordering_fields = ("creation_date",)
 
     def create(self, request, *args, **kwargs):
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index d23f07ce69..b5242eeb1c 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -8,7 +8,6 @@ from django.db.models.functions import Length
 from django.utils import timezone
 
 from rest_framework import mixins
-from rest_framework import permissions
 from rest_framework import settings as rest_settings
 from rest_framework import views, viewsets
 from rest_framework.decorators import action
@@ -24,6 +23,7 @@ from funkwhale_api.federation.authentication import SignatureAuthentication
 from funkwhale_api.federation import actors
 from funkwhale_api.federation import api_serializers as federation_api_serializers
 from funkwhale_api.federation import routes
+from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import filters, licenses, models, serializers, tasks, utils
 
@@ -64,7 +64,9 @@ class TagViewSetMixin(object):
 class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
     queryset = models.Artist.objects.all()
     serializer_class = serializers.ArtistWithAlbumsSerializer
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     filterset_class = filters.ArtistFilter
     ordering_fields = ("id", "name", "creation_date")
 
@@ -90,7 +92,9 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi
         models.Album.objects.all().order_by("artist", "release_date").select_related()
     )
     serializer_class = serializers.AlbumSerializer
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     ordering_fields = ("creation_date", "release_date", "title")
     filterset_class = filters.AlbumFilter
 
@@ -126,9 +130,11 @@ class LibraryViewSet(
     )
     serializer_class = serializers.LibraryForOwnerSerializer
     permission_classes = [
-        permissions.IsAuthenticated,
+        oauth_permissions.ScopePermission,
         common_permissions.OwnerPermission,
     ]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     owner_field = "actor.user"
     owner_checks = ["read", "write"]
 
@@ -178,7 +184,9 @@ class TrackViewSet(
 
     queryset = models.Track.objects.all().for_nested_serialization()
     serializer_class = serializers.TrackSerializer
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     filterset_class = filters.TrackFilter
     ordering_fields = (
         "creation_date",
@@ -350,7 +358,9 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
         rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
         + [SignatureAuthentication]
     )
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     lookup_field = "uuid"
 
     def retrieve(self, request, *args, **kwargs):
@@ -385,9 +395,11 @@ class UploadViewSet(
     )
     serializer_class = serializers.UploadForOwnerSerializer
     permission_classes = [
-        permissions.IsAuthenticated,
+        oauth_permissions.ScopePermission,
         common_permissions.OwnerPermission,
     ]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     owner_field = "library.actor.user"
     owner_checks = ["read", "write"]
     filterset_class = filters.UploadFilter
@@ -432,12 +444,16 @@ class UploadViewSet(
 class TagViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = Tag.objects.all().order_by("name")
     serializer_class = serializers.TagSerializer
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
 
 
 class Search(views.APIView):
     max_results = 3
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
 
     def get(self, request, *args, **kwargs):
         query = request.GET["query"]
@@ -502,7 +518,9 @@ class Search(views.APIView):
 
 
 class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
     serializer_class = serializers.LicenseSerializer
     queryset = models.License.objects.all().order_by("code")
     lookup_value_regex = ".*"
@@ -527,7 +545,9 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet):
 
 
 class OembedView(views.APIView):
-    permission_classes = [common_permissions.ConditionalAuthentication]
+    permission_classes = [oauth_permissions.ScopePermission]
+    required_scope = "libraries"
+    anonymous_policy = "setting"
 
     def get(self, request, *args, **kwargs):
         serializer = serializers.OembedSerializer(data=request.GET)
diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py
index 2f536d7a71..ee9b9c4e3a 100644
--- a/api/funkwhale_api/playlists/views.py
+++ b/api/funkwhale_api/playlists/views.py
@@ -2,11 +2,12 @@ from django.db import transaction
 from django.db.models import Count
 from rest_framework import exceptions, mixins, viewsets
 from rest_framework.decorators import action
-from rest_framework.permissions import IsAuthenticatedOrReadOnly
 from rest_framework.response import Response
 
 from funkwhale_api.common import fields, permissions
 from funkwhale_api.music import utils as music_utils
+from funkwhale_api.users.oauth import permissions as oauth_permissions
+
 from . import filters, models, serializers
 
 
@@ -28,10 +29,11 @@ class PlaylistViewSet(
         .with_duration()
     )
     permission_classes = [
-        permissions.ConditionalAuthentication,
+        oauth_permissions.ScopePermission,
         permissions.OwnerPermission,
-        IsAuthenticatedOrReadOnly,
     ]
+    required_scope = "playlists"
+    anonymous_policy = "setting"
     owner_checks = ["write"]
     filterset_class = filters.PlaylistFilter
     ordering_fields = ("id", "name", "creation_date", "modification_date")
@@ -101,10 +103,11 @@ class PlaylistTrackViewSet(
     serializer_class = serializers.PlaylistTrackSerializer
     queryset = models.PlaylistTrack.objects.all()
     permission_classes = [
-        permissions.ConditionalAuthentication,
+        oauth_permissions.ScopePermission,
         permissions.OwnerPermission,
-        IsAuthenticatedOrReadOnly,
     ]
+    required_scope = "playlists"
+    anonymous_policy = "setting"
     owner_field = "playlist.user"
     owner_checks = ["write"]
 
diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py
index 5df0fe287a..fa0a6fe4a6 100644
--- a/api/funkwhale_api/radios/views.py
+++ b/api/funkwhale_api/radios/views.py
@@ -5,6 +5,7 @@ from rest_framework.response import Response
 
 from funkwhale_api.common import permissions as common_permissions
 from funkwhale_api.music.serializers import TrackSerializer
+from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import filters, filtersets, models, serializers
 
@@ -20,10 +21,11 @@ class RadioViewSet(
 
     serializer_class = serializers.RadioSerializer
     permission_classes = [
-        permissions.IsAuthenticated,
+        oauth_permissions.ScopePermission,
         common_permissions.OwnerPermission,
     ]
     filterset_class = filtersets.RadioFilter
+    required_scope = "radios"
     owner_field = "user"
     owner_checks = ["write"]
 
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
index c0b26bf463..c40ed8ceda 100644
--- a/api/funkwhale_api/subsonic/views.py
+++ b/api/funkwhale_api/subsonic/views.py
@@ -92,7 +92,7 @@ def get_playlist_qs(request):
 class SubsonicViewSet(viewsets.GenericViewSet):
     content_negotiation_class = negotiation.SubsonicContentNegociation
     authentication_classes = [authentication.SubsonicAuthentication]
-    permissions_classes = [rest_permissions.IsAuthenticated]
+    permission_classes = [rest_permissions.IsAuthenticated]
 
     def dispatch(self, request, *args, **kwargs):
         if not preferences.get("subsonic__enabled"):
@@ -128,7 +128,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
         detail=False,
         methods=["get", "post"],
         url_name="get_license",
-        permissions_classes=[],
+        permission_classes=[],
         url_path="getLicense",
     )
     def get_license(self, request, *args, **kwargs):
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index a5b113cf45..e7f046ef3f 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,7 +1,7 @@
+import pytz
 import factory
 from django.contrib.auth.models import Permission
 from django.utils import timezone
-
 from funkwhale_api.factories import ManyToManyFromList, registry, NoUpdateOnCreate
 
 from . import models
@@ -87,3 +87,49 @@ class UserFactory(factory.django.DjangoModelFactory):
 class SuperUserFactory(UserFactory):
     is_staff = True
     is_superuser = True
+
+
+@registry.register
+class ApplicationFactory(factory.django.DjangoModelFactory):
+    name = factory.Faker("name")
+    redirect_uris = factory.Faker("url")
+    client_type = models.Application.CLIENT_CONFIDENTIAL
+    authorization_grant_type = models.Application.GRANT_AUTHORIZATION_CODE
+    scope = "read"
+
+    class Meta:
+        model = "users.Application"
+
+
+@registry.register
+class GrantFactory(factory.django.DjangoModelFactory):
+    application = factory.SubFactory(ApplicationFactory)
+    scope = factory.SelfAttribute(".application.scope")
+    redirect_uri = factory.SelfAttribute(".application.redirect_uris")
+    user = factory.SubFactory(UserFactory)
+    expires = factory.Faker("future_datetime", end_date="+15m")
+    code = factory.Faker("uuid4")
+
+    class Meta:
+        model = "users.Grant"
+
+
+@registry.register
+class AccessTokenFactory(factory.django.DjangoModelFactory):
+    application = factory.SubFactory(ApplicationFactory)
+    user = factory.SubFactory(UserFactory)
+    expires = factory.Faker("future_datetime", tzinfo=pytz.UTC)
+    token = factory.Faker("uuid4")
+
+    class Meta:
+        model = "users.AccessToken"
+
+
+@registry.register
+class RefreshTokenFactory(factory.django.DjangoModelFactory):
+    application = factory.SubFactory(ApplicationFactory)
+    user = factory.SubFactory(UserFactory)
+    token = factory.Faker("uuid4")
+
+    class Meta:
+        model = "users.RefreshToken"
diff --git a/api/funkwhale_api/users/migrations/0014_oauth.py b/api/funkwhale_api/users/migrations/0014_oauth.py
new file mode 100644
index 0000000000..696867f683
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0014_oauth.py
@@ -0,0 +1,195 @@
+# Generated by Django 2.0.9 on 2018-12-06 10:08
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.conf import settings
+import oauth2_provider.generators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("users", "0013_auto_20181206_1008"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="AccessToken",
+            fields=[
+                ("id", models.BigAutoField(primary_key=True, serialize=False)),
+                ("expires", models.DateTimeField()),
+                ("scope", models.TextField(blank=True)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("updated", models.DateTimeField(auto_now=True)),
+                ("token", models.CharField(max_length=255, unique=True)),
+            ],
+            options={"abstract": False},
+        ),
+        migrations.CreateModel(
+            name="Application",
+            fields=[
+                ("id", models.BigAutoField(primary_key=True, serialize=False)),
+                (
+                    "client_id",
+                    models.CharField(
+                        db_index=True,
+                        default=oauth2_provider.generators.generate_client_id,
+                        max_length=100,
+                        unique=True,
+                    ),
+                ),
+                (
+                    "redirect_uris",
+                    models.TextField(
+                        blank=True, help_text="Allowed URIs list, space separated"
+                    ),
+                ),
+                (
+                    "client_type",
+                    models.CharField(
+                        choices=[
+                            ("confidential", "Confidential"),
+                            ("public", "Public"),
+                        ],
+                        max_length=32,
+                    ),
+                ),
+                (
+                    "authorization_grant_type",
+                    models.CharField(
+                        choices=[
+                            ("authorization-code", "Authorization code"),
+                            ("implicit", "Implicit"),
+                            ("password", "Resource owner password-based"),
+                            ("client-credentials", "Client credentials"),
+                        ],
+                        max_length=32,
+                    ),
+                ),
+                (
+                    "client_secret",
+                    models.CharField(
+                        blank=True,
+                        db_index=True,
+                        default=oauth2_provider.generators.generate_client_secret,
+                        max_length=255,
+                    ),
+                ),
+                ("name", models.CharField(blank=True, max_length=255)),
+                ("skip_authorization", models.BooleanField(default=False)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("updated", models.DateTimeField(auto_now=True)),
+                (
+                    "user",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="users_application",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={"abstract": False},
+        ),
+        migrations.CreateModel(
+            name="Grant",
+            fields=[
+                ("id", models.BigAutoField(primary_key=True, serialize=False)),
+                ("code", models.CharField(max_length=255, unique=True)),
+                ("expires", models.DateTimeField()),
+                ("redirect_uri", models.CharField(max_length=255)),
+                ("scope", models.TextField(blank=True)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("updated", models.DateTimeField(auto_now=True)),
+                (
+                    "application",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="users.Application",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="users_grant",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={"abstract": False},
+        ),
+        migrations.CreateModel(
+            name="RefreshToken",
+            fields=[
+                ("id", models.BigAutoField(primary_key=True, serialize=False)),
+                ("token", models.CharField(max_length=255)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("updated", models.DateTimeField(auto_now=True)),
+                ("revoked", models.DateTimeField(null=True)),
+                (
+                    "access_token",
+                    models.OneToOneField(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="refresh_token",
+                        to="users.AccessToken",
+                    ),
+                ),
+                (
+                    "application",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="users.Application",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="users_refreshtoken",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={"abstract": False},
+        ),
+        migrations.AddField(
+            model_name="accesstoken",
+            name="application",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="users.Application",
+            ),
+        ),
+        migrations.AddField(
+            model_name="accesstoken",
+            name="source_refresh_token",
+            field=models.OneToOneField(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="refreshed_access_token",
+                to="users.RefreshToken",
+            ),
+        ),
+        migrations.AddField(
+            model_name="accesstoken",
+            name="user",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="users_accesstoken",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+        migrations.AlterUniqueTogether(
+            name="refreshtoken", unique_together={("token", "revoked")}
+        ),
+    ]
diff --git a/api/funkwhale_api/users/migrations/0015_application_scope.py b/api/funkwhale_api/users/migrations/0015_application_scope.py
new file mode 100644
index 0000000000..5fa8e52d75
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0015_application_scope.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.7 on 2019-03-18 09:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0014_oauth'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='application',
+            name='scope',
+            field=models.TextField(blank=True),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index b34693ed0b..d67dff4532 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -18,6 +18,8 @@ from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
 from django_auth_ldap.backend import populate_user as ldap_populate_user
+from oauth2_provider import models as oauth2_models
+from oauth2_provider import validators as oauth2_validators
 from versatileimagefield.fields import VersatileImageField
 from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 
@@ -37,12 +39,37 @@ PERMISSIONS_CONFIGURATION = {
     "moderation": {
         "label": "Moderation",
         "help_text": "Block/mute/remove domains, users and content",
+        "scopes": {
+            "read:instance:policies",
+            "write:instance:policies",
+            "read:instance:accounts",
+            "write:instance:accounts",
+            "read:instance:domains",
+            "write:instance:domains",
+        },
     },
     "library": {
         "label": "Manage library",
         "help_text": "Manage library, delete files, tracks, artists, albums...",
+        "scopes": {
+            "read:instance:edits",
+            "write:instance:edits",
+            "read:instance:libraries",
+            "write:instance:libraries",
+        },
+    },
+    "settings": {
+        "label": "Manage instance-level settings",
+        "help_text": "",
+        "scopes": {
+            "read:instance:settings",
+            "write:instance:settings",
+            "read:instance:users",
+            "write:instance:users",
+            "read:instance:invitations",
+            "write:instance:invitations",
+        },
     },
-    "settings": {"label": "Manage instance-level settings", "help_text": ""},
 }
 
 PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
@@ -245,6 +272,45 @@ class Invitation(models.Model):
         return super().save(**kwargs)
 
 
+class Application(oauth2_models.AbstractApplication):
+    scope = models.TextField(blank=True)
+
+    @property
+    def normalized_scopes(self):
+        from .oauth import permissions
+
+        raw_scopes = set(self.scope.split(" ") if self.scope else [])
+        return permissions.normalize(*raw_scopes)
+
+
+# oob schemes are not supported yet in oauth toolkit
+# (https://github.com/jazzband/django-oauth-toolkit/issues/235)
+# so in the meantime, we override their validation to add support
+OOB_SCHEMES = ["urn:ietf:wg:oauth:2.0:oob", "urn:ietf:wg:oauth:2.0:oob:auto"]
+
+
+class CustomRedirectURIValidator(oauth2_validators.RedirectURIValidator):
+    def __call__(self, value):
+        if value in OOB_SCHEMES:
+            return value
+        return super().__call__(value)
+
+
+oauth2_models.RedirectURIValidator = CustomRedirectURIValidator
+
+
+class Grant(oauth2_models.AbstractGrant):
+    pass
+
+
+class AccessToken(oauth2_models.AbstractAccessToken):
+    pass
+
+
+class RefreshToken(oauth2_models.AbstractRefreshToken):
+    pass
+
+
 def get_actor_data(username):
     slugified_username = federation_utils.slugify_username(username)
     return {
diff --git a/api/funkwhale_api/users/oauth/__init__.py b/api/funkwhale_api/users/oauth/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/funkwhale_api/users/oauth/permissions.py b/api/funkwhale_api/users/oauth/permissions.py
new file mode 100644
index 0000000000..ebd44a937a
--- /dev/null
+++ b/api/funkwhale_api/users/oauth/permissions.py
@@ -0,0 +1,123 @@
+from rest_framework import permissions
+from django.core.exceptions import ImproperlyConfigured
+
+from funkwhale_api.common import preferences
+
+from .. import models
+from . import scopes
+
+
+def normalize(*scope_ids):
+    """
+    Given an iterable containing scopes ids such as {read, write:playlists}
+    will return a set containing all the leaf scopes (and no parent scopes)
+    """
+    final = set()
+    for scope_id in scope_ids:
+        try:
+            scope_obj = scopes.SCOPES_BY_ID[scope_id]
+        except KeyError:
+            continue
+
+        if scope_obj.children:
+            final = final | {s.id for s in scope_obj.children}
+        else:
+            final.add(scope_obj.id)
+    return final
+
+
+def should_allow(required_scope, request_scopes):
+    if not required_scope:
+        return True
+
+    if not request_scopes:
+        return False
+
+    return required_scope in normalize(*request_scopes)
+
+
+METHOD_SCOPE_MAPPING = {
+    "get": "read",
+    "post": "write",
+    "patch": "write",
+    "put": "write",
+    "delete": "write",
+}
+
+
+class ScopePermission(permissions.BasePermission):
+    def has_permission(self, request, view):
+
+        if request.method.lower() in ["options", "head"]:
+            return True
+
+        try:
+            scope_config = getattr(view, "required_scope")
+        except AttributeError:
+            raise ImproperlyConfigured(
+                "ScopePermission requires the view to define the required_scope attribute"
+            )
+        anonymous_policy = getattr(view, "anonymous_policy", False)
+        if anonymous_policy not in [True, False, "setting"]:
+            raise ImproperlyConfigured(
+                "{} is not a valid value for anonymous_policy".format(anonymous_policy)
+            )
+        if isinstance(scope_config, str):
+            scope_config = {
+                "read": "read:{}".format(scope_config),
+                "write": "write:{}".format(scope_config),
+            }
+            action = METHOD_SCOPE_MAPPING[request.method.lower()]
+            required_scope = scope_config[action]
+        else:
+            # we have a dict with explicit viewset actions / scopes
+            required_scope = scope_config[view.action]
+
+        token = request.auth
+
+        if isinstance(token, models.AccessToken):
+            return self.has_permission_token(token, required_scope)
+        elif request.user.is_authenticated:
+            user_scopes = scopes.get_from_permissions(**request.user.get_permissions())
+            return should_allow(
+                required_scope=required_scope, request_scopes=user_scopes
+            )
+        elif hasattr(request, "actor") and request.actor:
+            # we use default anonymous scopes
+            user_scopes = scopes.FEDERATION_REQUEST_SCOPES
+            return should_allow(
+                required_scope=required_scope, request_scopes=user_scopes
+            )
+        else:
+            if anonymous_policy is False:
+                return False
+            if anonymous_policy == "setting" and preferences.get(
+                "common__api_authentication_required"
+            ):
+                return False
+
+            # we use default anonymous scopes
+            user_scopes = scopes.ANONYMOUS_SCOPES
+            return should_allow(
+                required_scope=required_scope, request_scopes=user_scopes
+            )
+
+    def has_permission_token(self, token, required_scope):
+
+        if token.is_expired():
+            return False
+
+        if not token.user:
+            return False
+
+        user = token.user
+        user_scopes = scopes.get_from_permissions(**user.get_permissions())
+        token_scopes = set(token.scopes.keys())
+        final_scopes = (
+            user_scopes
+            & normalize(*token_scopes)
+            & token.application.normalized_scopes
+            & scopes.OAUTH_APP_SCOPES
+        )
+
+        return should_allow(required_scope=required_scope, request_scopes=final_scopes)
diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py
new file mode 100644
index 0000000000..61b0709838
--- /dev/null
+++ b/api/funkwhale_api/users/oauth/scopes.py
@@ -0,0 +1,93 @@
+class Scope:
+    def __init__(self, id, label="", children=None):
+        self.id = id
+        self.label = ""
+        self.children = children or []
+
+    def copy(self, prefix):
+        return Scope("{}:{}".format(prefix, self.id))
+
+
+BASE_SCOPES = [
+    Scope(
+        "profile", "Access profile data (email, username, avatar, subsonic password…)"
+    ),
+    Scope("libraries", "Access uploads, libraries, and audio metadata"),
+    Scope("edits", "Browse and submit edits on audio metadata"),
+    Scope("follows", "Access library follows"),
+    Scope("favorites", "Access favorites"),
+    Scope("filters", "Access content filters"),
+    Scope("listenings", "Access listening history"),
+    Scope("radios", "Access radios"),
+    Scope("playlists", "Access playlists"),
+    Scope("notifications", "Access personal notifications"),
+    Scope("security", "Access security settings"),
+    # Privileged scopes that require specific user permissions
+    Scope("instance:settings", "Access instance settings"),
+    Scope("instance:users", "Access local user accounts"),
+    Scope("instance:invitations", "Access invitations"),
+    Scope("instance:edits", "Access instance metadata edits"),
+    Scope(
+        "instance:libraries", "Access instance uploads, libraries and audio metadata"
+    ),
+    Scope("instance:accounts", "Access instance federated accounts"),
+    Scope("instance:domains", "Access instance domains"),
+    Scope("instance:policies", "Access instance moderation policies"),
+]
+SCOPES = [
+    Scope("read", children=[s.copy("read") for s in BASE_SCOPES]),
+    Scope("write", children=[s.copy("write") for s in BASE_SCOPES]),
+]
+
+
+def flatten(*scopes):
+    for scope in scopes:
+        yield scope
+        yield from flatten(*scope.children)
+
+
+SCOPES_BY_ID = {s.id: s for s in flatten(*SCOPES)}
+
+FEDERATION_REQUEST_SCOPES = {"read:libraries"}
+ANONYMOUS_SCOPES = {
+    "read:libraries",
+    "read:playlists",
+    "read:listenings",
+    "read:favorites",
+    "read:radios",
+    "read:edits",
+}
+
+COMMON_SCOPES = ANONYMOUS_SCOPES | {
+    "read:profile",
+    "write:profile",
+    "write:libraries",
+    "write:playlists",
+    "read:follows",
+    "write:follows",
+    "write:favorites",
+    "read:notifications",
+    "write:notifications",
+    "write:radios",
+    "write:edits",
+    "read:filters",
+    "write:filters",
+    "write:listenings",
+}
+
+LOGGED_IN_SCOPES = COMMON_SCOPES | {"read:security", "write:security"}
+
+# We don't allow admin access for oauth apps yet
+OAUTH_APP_SCOPES = COMMON_SCOPES
+
+
+def get_from_permissions(**permissions):
+    from funkwhale_api.users import models
+
+    final = LOGGED_IN_SCOPES
+    for permission_name, value in permissions.items():
+        if value is False:
+            continue
+        config = models.PERMISSIONS_CONFIGURATION[permission_name]
+        final = final | config["scopes"]
+    return final
diff --git a/api/funkwhale_api/users/oauth/serializers.py b/api/funkwhale_api/users/oauth/serializers.py
new file mode 100644
index 0000000000..4788ba220a
--- /dev/null
+++ b/api/funkwhale_api/users/oauth/serializers.py
@@ -0,0 +1,29 @@
+from rest_framework import serializers
+
+from .. import models
+
+
+class ApplicationSerializer(serializers.ModelSerializer):
+    scopes = serializers.CharField(source="scope")
+
+    class Meta:
+        model = models.Application
+        fields = ["client_id", "name", "scopes", "created", "updated"]
+
+
+class CreateApplicationSerializer(serializers.ModelSerializer):
+    name = serializers.CharField(required=True, max_length=255)
+    scopes = serializers.CharField(source="scope", default="read")
+
+    class Meta:
+        model = models.Application
+        fields = [
+            "client_id",
+            "name",
+            "scopes",
+            "client_secret",
+            "created",
+            "updated",
+            "redirect_uris",
+        ]
+        read_only_fields = ["client_id", "client_secret", "created", "updated"]
diff --git a/api/funkwhale_api/users/oauth/tasks.py b/api/funkwhale_api/users/oauth/tasks.py
new file mode 100644
index 0000000000..2aaba353a7
--- /dev/null
+++ b/api/funkwhale_api/users/oauth/tasks.py
@@ -0,0 +1,8 @@
+from funkwhale_api.taskapp import celery
+
+from oauth2_provider import models as oauth2_models
+
+
+@celery.app.task(name="oauth.clear_expired_tokens")
+def clear_expired_tokens():
+    oauth2_models.clear_expired()
diff --git a/api/funkwhale_api/users/oauth/urls.py b/api/funkwhale_api/users/oauth/urls.py
new file mode 100644
index 0000000000..832f9ca1ba
--- /dev/null
+++ b/api/funkwhale_api/users/oauth/urls.py
@@ -0,0 +1,16 @@
+from django.conf.urls import url
+from django.views.decorators.csrf import csrf_exempt
+
+from rest_framework import routers
+
+from . import views
+
+router = routers.SimpleRouter()
+router.register(r"apps", views.ApplicationViewSet, "apps")
+router.register(r"grants", views.GrantViewSet, "grants")
+
+urlpatterns = router.urls + [
+    url("^authorize/$", csrf_exempt(views.AuthorizeView.as_view()), name="authorize"),
+    url("^token/$", views.TokenView.as_view(), name="token"),
+    url("^revoke/$", views.RevokeTokenView.as_view(), name="revoke"),
+]
diff --git a/api/funkwhale_api/users/oauth/views.py b/api/funkwhale_api/users/oauth/views.py
new file mode 100644
index 0000000000..a8bbb239ca
--- /dev/null
+++ b/api/funkwhale_api/users/oauth/views.py
@@ -0,0 +1,182 @@
+import json
+import urllib.parse
+
+from django import http
+from django.utils import timezone
+from django.db.models import Q
+from rest_framework import mixins, permissions, views, viewsets
+
+from oauth2_provider import exceptions as oauth2_exceptions
+from oauth2_provider import views as oauth_views
+from oauth2_provider.settings import oauth2_settings
+
+from .. import models
+from .permissions import ScopePermission
+from . import serializers
+
+
+class ApplicationViewSet(
+    mixins.CreateModelMixin,
+    mixins.ListModelMixin,
+    mixins.UpdateModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.RetrieveModelMixin,
+    viewsets.GenericViewSet,
+):
+    anonymous_policy = True
+    required_scope = {
+        "retrieve": None,
+        "create": None,
+        "destroy": "write:security",
+        "update": "write:security",
+        "partial_update": "write:security",
+        "list": "read:security",
+    }
+    lookup_field = "client_id"
+    queryset = models.Application.objects.all().order_by("-created")
+    serializer_class = serializers.ApplicationSerializer
+
+    def get_serializer_class(self):
+        if self.request.method.lower() == "post":
+            return serializers.CreateApplicationSerializer
+        return super().get_serializer_class()
+
+    def perform_create(self, serializer):
+        return serializer.save(
+            client_type=models.Application.CLIENT_CONFIDENTIAL,
+            authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE,
+            user=self.request.user if self.request.user.is_authenticated else None,
+        )
+
+    def get_serializer(self, *args, **kwargs):
+        serializer_class = self.get_serializer_class()
+        try:
+            owned = args[0].user == self.request.user
+        except (IndexError, AttributeError):
+            owned = False
+        if owned:
+            serializer_class = serializers.CreateApplicationSerializer
+
+        kwargs["context"] = self.get_serializer_context()
+        return serializer_class(*args, **kwargs)
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        if self.action in ["list", "destroy", "update", "partial_update"]:
+            qs = qs.filter(user=self.request.user)
+        return qs
+
+
+class GrantViewSet(
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.ListModelMixin,
+    viewsets.GenericViewSet,
+):
+    """
+    This is a viewset that list applications that have access to the request user
+    account, to allow revoking tokens easily.
+    """
+
+    permission_classes = [permissions.IsAuthenticated, ScopePermission]
+    required_scope = "security"
+    lookup_field = "client_id"
+    queryset = models.Application.objects.all().order_by("-created")
+    serializer_class = serializers.ApplicationSerializer
+    pagination_class = None
+
+    def get_queryset(self):
+        now = timezone.now()
+        queryset = super().get_queryset()
+        grants = models.Grant.objects.filter(user=self.request.user, expires__gt=now)
+        access_tokens = models.AccessToken.objects.filter(user=self.request.user)
+        refresh_tokens = models.RefreshToken.objects.filter(
+            user=self.request.user, revoked=None
+        )
+
+        return queryset.filter(
+            Q(pk__in=access_tokens.values("application"))
+            | Q(pk__in=refresh_tokens.values("application"))
+            | Q(pk__in=grants.values("application"))
+        ).distinct()
+
+    def perform_create(self, serializer):
+        return serializer.save(
+            client_type=models.Application.CLIENT_CONFIDENTIAL,
+            authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE,
+        )
+
+    def perform_destroy(self, instance):
+        application = instance
+
+        access_tokens = application.accesstoken_set.filter(user=self.request.user)
+        for token in access_tokens:
+            token.revoke()
+
+        refresh_tokens = application.refreshtoken_set.filter(user=self.request.user)
+        for token in refresh_tokens:
+            try:
+                token.revoke()
+            except models.AccessToken.DoesNotExist:
+                token.access_token = None
+                token.revoked = timezone.now()
+                token.save(update_fields=["access_token", "revoked"])
+        grants = application.grant_set.filter(user=self.request.user)
+        grants.delete()
+
+
+class AuthorizeView(views.APIView, oauth_views.AuthorizationView):
+    permission_classes = [permissions.IsAuthenticated]
+    server_class = oauth2_settings.OAUTH2_SERVER_CLASS
+    validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
+    oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS
+    skip_authorization_completely = False
+    oauth2_data = {}
+
+    def form_invalid(self, form):
+        """
+        Return a JSON response instead of a template one
+        """
+        errors = form.errors
+
+        return self.json_payload(errors, status_code=400)
+
+    def form_valid(self, form):
+        try:
+            response = super().form_valid(form)
+
+        except models.Application.DoesNotExist:
+            return self.json_payload({"non_field_errors": ["Invalid application"]}, 400)
+
+        if self.request.is_ajax() and response.status_code == 302:
+            # Web client need this to be able to redirect the user
+            query = urllib.parse.urlparse(response["Location"]).query
+            code = urllib.parse.parse_qs(query)["code"][0]
+            return self.json_payload(
+                {"redirect_uri": response["Location"], "code": code}, status_code=200
+            )
+
+        return response
+
+    def error_response(self, error, application):
+        if isinstance(error, oauth2_exceptions.FatalClientError):
+            return self.json_payload({"detail": error.oauthlib_error.description}, 400)
+        return super().error_response(error, application)
+
+    def json_payload(self, payload, status_code):
+        return http.HttpResponse(
+            json.dumps(payload), status=status_code, content_type="application/json"
+        )
+
+    def handle_no_permission(self):
+        return self.json_payload(
+            {"detail": "Authentication credentials were not provided."}, 401
+        )
+
+
+class TokenView(oauth_views.TokenView):
+    pass
+
+
+class RevokeTokenView(oauth_views.RevokeTokenView):
+    pass
diff --git a/api/funkwhale_api/users/permissions.py b/api/funkwhale_api/users/permissions.py
deleted file mode 100644
index 02c1198e8c..0000000000
--- a/api/funkwhale_api/users/permissions.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from rest_framework.permissions import BasePermission
-
-
-class HasUserPermission(BasePermission):
-    """
-    Ensure the request user has the proper permissions.
-
-    Usage:
-
-    class MyView(APIView):
-        permission_classes = [HasUserPermission]
-        required_permissions = ['federation']
-    """
-
-    def has_permission(self, request, view):
-        if not hasattr(request, "user") or not request.user:
-            return False
-        if request.user.is_anonymous:
-            return False
-        operator = getattr(view, "permission_operator", "and")
-        return request.user.has_permissions(
-            *view.required_permissions, operator=operator
-        )
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 2393882e72..85103afc69 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -11,6 +11,7 @@ from . import models, serializers
 
 class RegisterView(BaseRegisterView):
     serializer_class = serializers.RegisterSerializer
+    permission_classes = []
 
     def create(self, request, *args, **kwargs):
         invitation_code = request.data.get("invitation")
@@ -27,6 +28,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
     queryset = models.User.objects.all()
     serializer_class = serializers.UserWriteSerializer
     lookup_field = "username"
+    required_scope = "profile"
 
     @action(methods=["get"], detail=False)
     def me(self, request, *args, **kwargs):
@@ -34,7 +36,12 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
         serializer = serializers.MeSerializer(request.user)
         return Response(serializer.data)
 
-    @action(methods=["get", "post", "delete"], url_path="subsonic-token", detail=True)
+    @action(
+        methods=["get", "post", "delete"],
+        required_scope="security",
+        url_path="subsonic-token",
+        detail=True,
+    )
     def subsonic_token(self, request, *args, **kwargs):
         if not self.request.user.username == kwargs.get("username"):
             return Response(status=403)
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index 8b4639fee6..1a935cd8e7 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -68,3 +68,5 @@ pydub==0.23.0
 pyld==1.0.4
 aiohttp==3.5.4
 autobahn>=19.3.2
+
+django-oauth-toolkit==1.2
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index c4f65ea183..47b3e417f1 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -29,7 +29,6 @@ from rest_framework.test import APIClient, APIRequestFactory
 
 from funkwhale_api.activity import record
 from funkwhale_api.federation import actors
-from funkwhale_api.users.permissions import HasUserPermission
 
 
 pytest_plugins = "aiohttp.pytest_plugin"
@@ -317,16 +316,6 @@ def authenticated_actor(factories, mocker):
     yield actor
 
 
-@pytest.fixture
-def assert_user_permission():
-    def inner(view, permissions, operator="and"):
-        assert HasUserPermission in view.permission_classes
-        assert getattr(view, "permission_operator", "and") == operator
-        assert set(view.required_permissions) == set(permissions)
-
-    return inner
-
-
 @pytest.fixture
 def to_api_date():
     def inner(value):
diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py
index 7e8d1d3fdd..190c791843 100644
--- a/api/tests/favorites/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -17,12 +17,14 @@ def test_user_can_add_favorite(factories):
     assert f.user == user
 
 
-def test_user_can_get_his_favorites(api_request, factories, logged_in_client, client):
+def test_user_can_get_his_favorites(
+    api_request, factories, logged_in_api_client, client
+):
     r = api_request.get("/")
-    favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user)
+    favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
     factories["favorites.TrackFavorite"]()
     url = reverse("api:v1:favorites:tracks-list")
-    response = logged_in_client.get(url, {"user": logged_in_client.user.pk})
+    response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk})
     expected = [
         {
             "user": users_serializers.UserBasicSerializer(
@@ -40,21 +42,21 @@ def test_user_can_get_his_favorites(api_request, factories, logged_in_client, cl
 
 
 def test_user_can_retrieve_all_favorites_at_once(
-    api_request, factories, logged_in_client, client
+    api_request, factories, logged_in_api_client, client
 ):
-    favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user)
+    favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
     factories["favorites.TrackFavorite"]()
     url = reverse("api:v1:favorites:tracks-all")
-    response = logged_in_client.get(url, {"user": logged_in_client.user.pk})
+    response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk})
     expected = [{"track": favorite.track.id, "id": favorite.id}]
     assert response.status_code == 200
     assert response.data["results"] == expected
 
 
-def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_muted):
+def test_user_can_add_favorite_via_api(factories, logged_in_api_client, activity_muted):
     track = factories["music.Track"]()
     url = reverse("api:v1:favorites:tracks-list")
-    response = logged_in_client.post(url, {"track": track.pk})
+    response = logged_in_api_client.post(url, {"track": track.pk})
 
     favorite = TrackFavorite.objects.latest("id")
     expected = {
@@ -66,15 +68,15 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_mut
 
     assert expected == parsed_json
     assert favorite.track == track
-    assert favorite.user == logged_in_client.user
+    assert favorite.user == logged_in_api_client.user
 
 
 def test_adding_favorites_calls_activity_record(
-    factories, logged_in_client, activity_muted
+    factories, logged_in_api_client, activity_muted
 ):
     track = factories["music.Track"]()
     url = reverse("api:v1:favorites:tracks-list")
-    response = logged_in_client.post(url, {"track": track.pk})
+    response = logged_in_api_client.post(url, {"track": track.pk})
 
     favorite = TrackFavorite.objects.latest("id")
     expected = {
@@ -86,27 +88,27 @@ def test_adding_favorites_calls_activity_record(
 
     assert expected == parsed_json
     assert favorite.track == track
-    assert favorite.user == logged_in_client.user
+    assert favorite.user == logged_in_api_client.user
 
     activity_muted.assert_called_once_with(favorite)
 
 
-def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
-    favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user)
+def test_user_can_remove_favorite_via_api(logged_in_api_client, factories):
+    favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
     url = reverse("api:v1:favorites:tracks-detail", kwargs={"pk": favorite.pk})
-    response = client.delete(url, {"track": favorite.track.pk})
+    response = logged_in_api_client.delete(url, {"track": favorite.track.pk})
     assert response.status_code == 204
     assert TrackFavorite.objects.count() == 0
 
 
 @pytest.mark.parametrize("method", ["delete", "post"])
 def test_user_can_remove_favorite_via_api_using_track_id(
-    method, factories, logged_in_client
+    method, factories, logged_in_api_client
 ):
-    favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user)
+    favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
 
     url = reverse("api:v1:favorites:tracks-remove")
-    response = getattr(logged_in_client, method)(
+    response = getattr(logged_in_api_client, method)(
         url, json.dumps({"track": favorite.track.pk}), content_type="application/json"
     )
 
@@ -122,11 +124,11 @@ def test_url_require_auth(url, method, db, preferences, client):
     assert response.status_code == 401
 
 
-def test_can_filter_tracks_by_favorites(factories, logged_in_client):
-    favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user)
+def test_can_filter_tracks_by_favorites(factories, logged_in_api_client):
+    favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
 
     url = reverse("api:v1:tracks-list")
-    response = logged_in_client.get(url, data={"favorites": True})
+    response = logged_in_api_client.get(url, data={"favorites": True})
 
     parsed_json = json.loads(response.content.decode("utf-8"))
     assert parsed_json["count"] == 1
diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py
index 6dd4f6345a..0b3b7b79ad 100644
--- a/api/tests/instance/test_views.py
+++ b/api/tests/instance/test_views.py
@@ -1,13 +1,5 @@
-import pytest
 from django.urls import reverse
 
-from funkwhale_api.instance import views
-
-
-@pytest.mark.parametrize("view,permissions", [(views.AdminSettings, ["settings"])])
-def test_permissions(assert_user_permission, view, permissions):
-    assert_user_permission(view, permissions)
-
 
 def test_nodeinfo_endpoint(db, api_client, mocker):
     payload = {"test": "test"}
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index 6402fb6505..10db666256 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -3,22 +3,7 @@ from django.urls import reverse
 
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import tasks as federation_tasks
-from funkwhale_api.manage import serializers, views
-
-
-@pytest.mark.parametrize(
-    "view,permissions,operator",
-    [
-        (views.ManageUploadViewSet, ["library"], "and"),
-        (views.ManageUserViewSet, ["settings"], "and"),
-        (views.ManageInvitationViewSet, ["settings"], "and"),
-        (views.ManageDomainViewSet, ["moderation"], "and"),
-        (views.ManageActorViewSet, ["moderation"], "and"),
-        (views.ManageInstancePolicyViewSet, ["moderation"], "and"),
-    ],
-)
-def test_permissions(assert_user_permission, view, permissions, operator):
-    assert_user_permission(view, permissions, operator)
+from funkwhale_api.manage import serializers
 
 
 @pytest.mark.skip(reason="Refactoring in progress")
diff --git a/api/tests/users/oauth/__init__.py b/api/tests/users/oauth/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/users/oauth/test_api_permissions.py b/api/tests/users/oauth/test_api_permissions.py
new file mode 100644
index 0000000000..aaac8430b5
--- /dev/null
+++ b/api/tests/users/oauth/test_api_permissions.py
@@ -0,0 +1,79 @@
+import pytest
+import uuid
+
+from django.urls import reverse
+
+from funkwhale_api.users.oauth import scopes
+
+# mutations
+
+
+@pytest.mark.parametrize(
+    "name, url_kwargs, scope, method",
+    [
+        ("api:v1:search", {}, "read:libraries", "get"),
+        ("api:v1:artists-list", {}, "read:libraries", "get"),
+        ("api:v1:albums-list", {}, "read:libraries", "get"),
+        ("api:v1:tracks-list", {}, "read:libraries", "get"),
+        ("api:v1:tracks-mutations", {"pk": 42}, "read:edits", "get"),
+        ("api:v1:tags-list", {}, "read:libraries", "get"),
+        ("api:v1:licenses-list", {}, "read:libraries", "get"),
+        ("api:v1:moderation:content-filters-list", {}, "read:filters", "get"),
+        ("api:v1:listen-detail", {"uuid": uuid.uuid4()}, "read:libraries", "get"),
+        ("api:v1:uploads-list", {}, "read:libraries", "get"),
+        ("api:v1:playlists-list", {}, "read:playlists", "get"),
+        ("api:v1:playlist-tracks-list", {}, "read:playlists", "get"),
+        ("api:v1:favorites:tracks-list", {}, "read:favorites", "get"),
+        ("api:v1:history:listenings-list", {}, "read:listenings", "get"),
+        ("api:v1:radios:radios-list", {}, "read:radios", "get"),
+        ("api:v1:oauth:grants-list", {}, "read:security", "get"),
+        ("api:v1:federation:inbox-list", {}, "read:notifications", "get"),
+        (
+            "api:v1:federation:libraries-detail",
+            {"uuid": uuid.uuid4()},
+            "read:libraries",
+            "get",
+        ),
+        ("api:v1:federation:library-follows-list", {}, "read:follows", "get"),
+        # admin / privileged stuff
+        ("api:v1:instance:admin-settings-list", {}, "read:instance:settings", "get"),
+        (
+            "api:v1:manage:users:invitations-list",
+            {},
+            "read:instance:invitations",
+            "get",
+        ),
+        ("api:v1:manage:users:users-list", {}, "read:instance:users", "get"),
+        ("api:v1:manage:library:uploads-list", {}, "read:instance:libraries", "get"),
+        ("api:v1:manage:accounts-list", {}, "read:instance:accounts", "get"),
+        ("api:v1:manage:federation:domains-list", {}, "read:instance:domains", "get"),
+        (
+            "api:v1:manage:moderation:instance-policies-list",
+            {},
+            "read:instance:policies",
+            "get",
+        ),
+    ],
+)
+def test_views_permissions(
+    name, url_kwargs, scope, method, mocker, logged_in_api_client
+):
+    """
+    Smoke tests to ensure viewsets are correctly protected
+    """
+    url = reverse(name, kwargs=url_kwargs)
+    user_scopes = scopes.get_from_permissions(
+        **logged_in_api_client.user.get_permissions()
+    )
+
+    should_allow = mocker.patch(
+        "funkwhale_api.users.oauth.permissions.should_allow", return_value=False
+    )
+    handler = getattr(logged_in_api_client, method)
+    response = handler(url)
+    should_allow.assert_called_once_with(
+        required_scope=scope, request_scopes=user_scopes
+    )
+    assert response.status_code == 403, "{} on {} is not protected correctly!".format(
+        method, url
+    )
diff --git a/api/tests/users/oauth/test_models.py b/api/tests/users/oauth/test_models.py
new file mode 100644
index 0000000000..1b27900ee8
--- /dev/null
+++ b/api/tests/users/oauth/test_models.py
@@ -0,0 +1,21 @@
+import pytest
+
+from django import forms
+
+from funkwhale_api.users import models
+
+
+@pytest.mark.parametrize(
+    "uri",
+    ["urn:ietf:wg:oauth:2.0:oob", "urn:ietf:wg:oauth:2.0:oob:auto", "http://test.com"],
+)
+def test_redirect_uris_oob(uri, db):
+    app = models.Application(redirect_uris=uri)
+    assert app.clean() is None
+
+
+@pytest.mark.parametrize("uri", ["urn:ietf:wg:oauth:2.0:invalid", "noop"])
+def test_redirect_uris_invalid(uri, db):
+    app = models.Application(redirect_uris=uri)
+    with pytest.raises(forms.ValidationError):
+        app.clean()
diff --git a/api/tests/users/oauth/test_permissions.py b/api/tests/users/oauth/test_permissions.py
new file mode 100644
index 0000000000..65974fbf64
--- /dev/null
+++ b/api/tests/users/oauth/test_permissions.py
@@ -0,0 +1,241 @@
+import pytest
+
+from funkwhale_api.users.oauth import scopes
+from funkwhale_api.users.oauth import permissions
+
+
+@pytest.mark.parametrize(
+    "required_scope, request_scopes, expected",
+    [
+        (None, {}, True),
+        ("write:profile", {"write"}, True),
+        ("write:profile", {"read"}, False),
+        ("write:profile", {"read:profile"}, False),
+        ("write:profile", {"write:profile"}, True),
+        ("read:profile", {"read"}, True),
+        ("read:profile", {"write"}, False),
+        ("read:profile", {"read:profile"}, True),
+        ("read:profile", {"write:profile"}, False),
+        ("write:profile", {"write"}, True),
+        ("write:profile", {"read:profile"}, False),
+        ("write:profile", {"write:profile"}, True),
+        ("write:profile", {"write"}, True),
+        ("write:profile", {"read:profile"}, False),
+        ("write:profile", {"write:profile"}, True),
+        ("write:profile", {"write"}, True),
+        ("write:profile", {"read:profile"}, False),
+        ("write:profile", {"write:profile"}, True),
+    ],
+)
+def test_should_allow(required_scope, request_scopes, expected):
+    assert (
+        permissions.should_allow(
+            required_scope=required_scope, request_scopes=request_scopes
+        )
+        is expected
+    )
+
+
+@pytest.mark.parametrize("method", ["OPTIONS", "HEAD"])
+def test_scope_permission_safe_methods(method, mocker, factories):
+    view = mocker.Mock(required_scope="write:profile", anonymous_policy=False)
+    request = mocker.Mock(method=method)
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) is True
+
+
+@pytest.mark.parametrize(
+    "policy, preference, expected",
+    [
+        (True, False, True),
+        (False, False, False),
+        ("setting", True, False),
+        ("setting", False, True),
+    ],
+)
+def test_scope_permission_anonymous_policy(
+    policy, preference, expected, preferences, mocker, anonymous_user
+):
+    preferences["common__api_authentication_required"] = preference
+    view = mocker.Mock(required_scope="libraries", anonymous_policy=policy)
+    request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) is expected
+
+
+def test_scope_permission_dict_no_required(mocker, anonymous_user):
+    view = mocker.Mock(
+        required_scope={"read": None, "write": "write:profile"},
+        anonymous_policy=True,
+        action="read",
+    )
+    request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) is True
+
+
+@pytest.mark.parametrize(
+    "required_scope, method, action, expected_scope",
+    [
+        ("profile", "GET", "read", "read:profile"),
+        ("profile", "POST", "write", "write:profile"),
+        ({"read": "read:profile"}, "GET", "read", "read:profile"),
+        ({"write": "write:profile"}, "POST", "write", "write:profile"),
+    ],
+)
+def test_scope_permission_user(
+    required_scope, method, action, expected_scope, mocker, factories
+):
+    user = factories["users.User"]()
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method=method, user=user, actor=None)
+    view = mocker.Mock(
+        required_scope=required_scope, anonymous_policy=False, action=action
+    )
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) == should_allow.return_value
+
+    should_allow.assert_called_once_with(
+        required_scope=expected_scope,
+        request_scopes=scopes.get_from_permissions(**user.get_permissions()),
+    )
+
+
+def test_scope_permission_token(mocker, factories):
+    token = factories["users.AccessToken"](
+        scope="write:profile read:playlists",
+        application__scope="write:profile read:playlists",
+    )
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", auth=token)
+    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
+    )
+    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=scopes.FEDERATION_REQUEST_SCOPES
+    )
+
+
+def test_scope_permission_token_anonymous_user_auth_required(
+    mocker, factories, anonymous_user, preferences
+):
+    preferences["common__api_authentication_required"] = True
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
+    view = mocker.Mock(required_scope="profile", anonymous_policy=False)
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) is False
+
+    should_allow.assert_not_called()
+
+
+def test_scope_permission_token_anonymous_user_auth_not_required(
+    mocker, factories, anonymous_user, preferences
+):
+    preferences["common__api_authentication_required"] = False
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
+    view = mocker.Mock(required_scope="profile", anonymous_policy="setting")
+
+    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=scopes.ANONYMOUS_SCOPES
+    )
+
+
+def test_scope_permission_token_expired(mocker, factories, now):
+    token = factories["users.AccessToken"](
+        scope="profile:write playlists:read", expires=now
+    )
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", auth=token)
+    view = mocker.Mock(required_scope="profile", anonymous_policy=False)
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) is False
+
+    should_allow.assert_not_called()
+
+
+def test_scope_permission_token_no_user(mocker, factories, now):
+    token = factories["users.AccessToken"](
+        scope="profile:write playlists:read", user=None
+    )
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", auth=token)
+    view = mocker.Mock(required_scope="profile", anonymous_policy=False)
+
+    p = permissions.ScopePermission()
+
+    assert p.has_permission(request, view) is False
+
+    should_allow.assert_not_called()
+
+
+def test_scope_permission_token_honor_app_scopes(mocker, factories, now):
+    # token contains read access, but app scope only allows profile:write
+    token = factories["users.AccessToken"](
+        scope="write:profile read", application__scope="write:profile"
+    )
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", auth=token)
+    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"}
+    )
+
+
+def test_scope_permission_token_honor_allowed_app_scopes(mocker, factories, now):
+    mocker.patch.object(scopes, "OAUTH_APP_SCOPES", {"read:profile"})
+    token = factories["users.AccessToken"](
+        scope="write:profile read:profile read",
+        application__scope="write:profile read:profile read",
+    )
+    should_allow = mocker.patch.object(permissions, "should_allow")
+    request = mocker.Mock(method="POST", auth=token)
+    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={"read:profile"}
+    )
diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py
new file mode 100644
index 0000000000..3d12cb664e
--- /dev/null
+++ b/api/tests/users/oauth/test_scopes.py
@@ -0,0 +1,156 @@
+import pytest
+
+from funkwhale_api.users.oauth import scopes
+
+
+@pytest.mark.parametrize(
+    "user_perms, expected",
+    [
+        (
+            # All permissions, so all scopes
+            {"moderation": True, "library": True, "settings": True},
+            {
+                "read:profile",
+                "write:profile",
+                "read:libraries",
+                "write:libraries",
+                "read:playlists",
+                "write:playlists",
+                "read:favorites",
+                "write:favorites",
+                "read:notifications",
+                "write:notifications",
+                "read:radios",
+                "write:radios",
+                "read:follows",
+                "write:follows",
+                "read:edits",
+                "write:edits",
+                "read:filters",
+                "write:filters",
+                "read:listenings",
+                "write:listenings",
+                "read:security",
+                "write:security",
+                "read:instance:policies",
+                "write:instance:policies",
+                "read:instance:accounts",
+                "write:instance:accounts",
+                "read:instance:domains",
+                "write:instance:domains",
+                "read:instance:settings",
+                "write:instance:settings",
+                "read:instance:users",
+                "write:instance:users",
+                "read:instance:invitations",
+                "write:instance:invitations",
+                "read:instance:edits",
+                "write:instance:edits",
+                "read:instance:libraries",
+                "write:instance:libraries",
+            },
+        ),
+        (
+            {"moderation": True, "library": False, "settings": True},
+            {
+                "read:profile",
+                "write:profile",
+                "read:libraries",
+                "write:libraries",
+                "read:playlists",
+                "write:playlists",
+                "read:favorites",
+                "write:favorites",
+                "read:notifications",
+                "write:notifications",
+                "read:radios",
+                "write:radios",
+                "read:follows",
+                "write:follows",
+                "read:edits",
+                "write:edits",
+                "read:filters",
+                "write:filters",
+                "read:listenings",
+                "write:listenings",
+                "read:security",
+                "write:security",
+                "read:instance:policies",
+                "write:instance:policies",
+                "read:instance:accounts",
+                "write:instance:accounts",
+                "read:instance:domains",
+                "write:instance:domains",
+                "read:instance:settings",
+                "write:instance:settings",
+                "read:instance:users",
+                "write:instance:users",
+                "read:instance:invitations",
+                "write:instance:invitations",
+            },
+        ),
+        (
+            {"moderation": True, "library": False, "settings": False},
+            {
+                "read:profile",
+                "write:profile",
+                "read:libraries",
+                "write:libraries",
+                "read:playlists",
+                "write:playlists",
+                "read:favorites",
+                "write:favorites",
+                "read:notifications",
+                "write:notifications",
+                "read:radios",
+                "write:radios",
+                "read:follows",
+                "write:follows",
+                "read:edits",
+                "write:edits",
+                "read:filters",
+                "write:filters",
+                "read:listenings",
+                "write:listenings",
+                "read:security",
+                "write:security",
+                "read:instance:policies",
+                "write:instance:policies",
+                "read:instance:accounts",
+                "write:instance:accounts",
+                "read:instance:domains",
+                "write:instance:domains",
+            },
+        ),
+        (
+            {"moderation": False, "library": False, "settings": False},
+            {
+                "read:profile",
+                "write:profile",
+                "read:libraries",
+                "write:libraries",
+                "read:playlists",
+                "write:playlists",
+                "read:favorites",
+                "write:favorites",
+                "read:notifications",
+                "write:notifications",
+                "read:radios",
+                "write:radios",
+                "read:follows",
+                "write:follows",
+                "read:edits",
+                "write:edits",
+                "read:filters",
+                "write:filters",
+                "read:listenings",
+                "write:listenings",
+                "read:security",
+                "write:security",
+            },
+        ),
+    ],
+)
+def test_get_scopes_from_user_permissions(user_perms, expected):
+
+    assert scopes.get_from_permissions(**user_perms) == expected
diff --git a/api/tests/users/oauth/test_tasks.py b/api/tests/users/oauth/test_tasks.py
new file mode 100644
index 0000000000..c30f248837
--- /dev/null
+++ b/api/tests/users/oauth/test_tasks.py
@@ -0,0 +1,10 @@
+from oauth2_provider import models
+from funkwhale_api.users.oauth import tasks
+
+
+def test_clear_expired_tokens(mocker, db):
+    clear_expired = mocker.spy(models, "clear_expired")
+
+    tasks.clear_expired_tokens()
+
+    clear_expired.assert_called_once()
diff --git a/api/tests/users/oauth/test_views.py b/api/tests/users/oauth/test_views.py
new file mode 100644
index 0000000000..19d2587097
--- /dev/null
+++ b/api/tests/users/oauth/test_views.py
@@ -0,0 +1,363 @@
+import json
+import pytest
+
+from django.urls import reverse
+
+from funkwhale_api.users import models
+from funkwhale_api.users.oauth import serializers
+
+
+def test_apps_post(api_client, db):
+    url = reverse("api:v1:oauth:apps-list")
+    data = {
+        "name": "Test app",
+        "redirect_uris": "http://test.app",
+        "scopes": "read write:profile",
+    }
+    response = api_client.post(url, data)
+
+    assert response.status_code == 201
+
+    app = models.Application.objects.get(name=data["name"])
+
+    assert app.client_type == models.Application.CLIENT_CONFIDENTIAL
+    assert app.authorization_grant_type == models.Application.GRANT_AUTHORIZATION_CODE
+    assert app.redirect_uris == data["redirect_uris"]
+    assert response.data == serializers.CreateApplicationSerializer(app).data
+    assert app.scope == "read write:profile"
+    assert app.user is None
+
+
+def test_apps_post_logged_in_user(logged_in_api_client, db):
+    url = reverse("api:v1:oauth:apps-list")
+    data = {
+        "name": "Test app",
+        "redirect_uris": "http://test.app",
+        "scopes": "read write:profile",
+    }
+    response = logged_in_api_client.post(url, data)
+
+    assert response.status_code == 201
+
+    app = models.Application.objects.get(name=data["name"])
+
+    assert app.client_type == models.Application.CLIENT_CONFIDENTIAL
+    assert app.authorization_grant_type == models.Application.GRANT_AUTHORIZATION_CODE
+    assert app.redirect_uris == data["redirect_uris"]
+    assert response.data == serializers.CreateApplicationSerializer(app).data
+    assert app.scope == "read write:profile"
+    assert app.user == logged_in_api_client.user
+
+
+def test_apps_list_anonymous(api_client, db):
+    url = reverse("api:v1:oauth:apps-list")
+    response = api_client.get(url)
+
+    assert response.status_code == 401
+
+
+def test_apps_list_logged_in(factories, logged_in_api_client, db):
+    app = factories["users.Application"](user=logged_in_api_client.user)
+    factories["users.Application"]()
+    url = reverse("api:v1:oauth:apps-list")
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data["results"] == [serializers.ApplicationSerializer(app).data]
+
+
+def test_apps_delete_not_owner(factories, logged_in_api_client, db):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id})
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 404
+
+
+def test_apps_delete_owner(factories, logged_in_api_client, db):
+    app = factories["users.Application"](user=logged_in_api_client.user)
+    url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id})
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+
+    with pytest.raises(app.DoesNotExist):
+        app.refresh_from_db()
+
+
+def test_apps_update_not_owner(factories, logged_in_api_client, db):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id})
+    response = logged_in_api_client.patch(url, {"name": "Hello"})
+
+    assert response.status_code == 404
+
+
+def test_apps_update_owner(factories, logged_in_api_client, db):
+    app = factories["users.Application"](user=logged_in_api_client.user)
+    url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id})
+    response = logged_in_api_client.patch(url, {"name": "Hello"})
+
+    assert response.status_code == 200
+    app.refresh_from_db()
+
+    assert app.name == "Hello"
+
+
+def test_apps_get(preferences, logged_in_api_client, factories):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id})
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == serializers.ApplicationSerializer(app).data
+
+
+def test_apps_get_owner(preferences, logged_in_api_client, factories):
+    app = factories["users.Application"](user=logged_in_api_client.user)
+    url = reverse("api:v1:oauth:apps-detail", kwargs={"client_id": app.client_id})
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == serializers.CreateApplicationSerializer(app).data
+
+
+def test_authorize_view_post(logged_in_client, factories):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:authorize")
+    response = logged_in_client.post(
+        url,
+        {
+            "allow": True,
+            "redirect_uri": app.redirect_uris,
+            "client_id": app.client_id,
+            "state": "hello",
+            "response_type": "code",
+            "scope": "read",
+        },
+    )
+    grant = models.Grant.objects.get(application=app)
+    assert response.status_code == 302
+    assert response["Location"] == "{}?code={}&state={}".format(
+        app.redirect_uris, grant.code, "hello"
+    )
+
+
+def test_authorize_view_post_ajax_no_redirect(logged_in_client, factories):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:authorize")
+    response = logged_in_client.post(
+        url,
+        {
+            "allow": True,
+            "redirect_uri": app.redirect_uris,
+            "client_id": app.client_id,
+            "state": "hello",
+            "response_type": "code",
+            "scope": "read",
+        },
+        HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+    )
+    assert response.status_code == 200
+    grant = models.Grant.objects.get(application=app)
+    assert json.loads(response.content.decode()) == {
+        "redirect_uri": "{}?code={}&state={}".format(
+            app.redirect_uris, grant.code, "hello"
+        ),
+        "code": grant.code,
+    }
+
+
+def test_authorize_view_post_ajax_oob(logged_in_client, factories):
+    app = factories["users.Application"](redirect_uris="urn:ietf:wg:oauth:2.0:oob")
+    url = reverse("api:v1:oauth:authorize")
+    response = logged_in_client.post(
+        url,
+        {
+            "allow": True,
+            "redirect_uri": app.redirect_uris,
+            "client_id": app.client_id,
+            "state": "hello",
+            "response_type": "code",
+            "scope": "read",
+        },
+        HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+    )
+    assert response.status_code == 200
+    grant = models.Grant.objects.get(application=app)
+    assert json.loads(response.content.decode()) == {
+        "redirect_uri": "{}?code={}&state={}".format(
+            app.redirect_uris, grant.code, "hello"
+        ),
+        "code": grant.code,
+    }
+
+
+def test_authorize_view_invalid_form(logged_in_client, factories):
+    url = reverse("api:v1:oauth:authorize")
+    response = logged_in_client.post(
+        url,
+        {
+            "allow": True,
+            "redirect_uri": "",
+            "client_id": "Noop",
+            "state": "hello",
+            "response_type": "code",
+            "scope": "read",
+        },
+    )
+
+    assert response.status_code == 400
+    assert json.loads(response.content.decode()) == {
+        "redirect_uri": ["This field is required."]
+    }
+
+
+def test_authorize_view_invalid_redirect_url(logged_in_client, factories):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:authorize")
+    response = logged_in_client.post(
+        url,
+        {
+            "allow": True,
+            "redirect_uri": "http://wrong.url",
+            "client_id": app.client_id,
+            "state": "hello",
+            "response_type": "code",
+            "scope": "read",
+        },
+    )
+
+    assert response.status_code == 400
+    assert json.loads(response.content.decode()) == {
+        "detail": "Mismatching redirect URI."
+    }
+
+
+def test_authorize_view_invalid_oauth(logged_in_client, factories):
+    app = factories["users.Application"]()
+    url = reverse("api:v1:oauth:authorize")
+    response = logged_in_client.post(
+        url,
+        {
+            "allow": True,
+            "redirect_uri": app.redirect_uris,
+            "client_id": "wrong_id",
+            "state": "hello",
+            "response_type": "code",
+            "scope": "read",
+        },
+    )
+
+    assert response.status_code == 400
+    assert json.loads(response.content.decode()) == {
+        "non_field_errors": ["Invalid application"]
+    }
+
+
+def test_authorize_view_anonymous(client, factories):
+    url = reverse("api:v1:oauth:authorize")
+    response = client.post(url, {})
+
+    assert response.status_code == 401
+
+
+def test_token_view_post(api_client, factories):
+    grant = factories["users.Grant"]()
+    app = grant.application
+    url = reverse("api:v1:oauth:token")
+
+    response = api_client.post(
+        url,
+        {
+            "redirect_uri": app.redirect_uris,
+            "client_id": app.client_id,
+            "client_secret": app.client_secret,
+            "grant_type": "authorization_code",
+            "code": grant.code,
+        },
+    )
+    payload = json.loads(response.content.decode())
+
+    assert "access_token" in payload
+    assert "refresh_token" in payload
+    assert payload["expires_in"] == 36000
+    assert payload["scope"] == grant.scope
+    assert payload["token_type"] == "Bearer"
+    assert response.status_code == 200
+
+    with pytest.raises(grant.DoesNotExist):
+        grant.refresh_from_db()
+
+
+def test_revoke_view_post(logged_in_client, factories):
+    token = factories["users.AccessToken"]()
+    url = reverse("api:v1:oauth:revoke")
+
+    response = logged_in_client.post(
+        url,
+        {
+            "token": token.token,
+            "client_id": token.application.client_id,
+            "client_secret": token.application.client_secret,
+        },
+    )
+    assert response.status_code == 200
+
+    with pytest.raises(token.DoesNotExist):
+        token.refresh_from_db()
+
+
+def test_grants_list(factories, logged_in_api_client):
+    token = factories["users.AccessToken"](user=logged_in_api_client.user)
+    refresh_token = factories["users.RefreshToken"](user=logged_in_api_client.user)
+    factories["users.AccessToken"]()
+    url = reverse("api:v1:oauth:grants-list")
+    expected = [
+        serializers.ApplicationSerializer(refresh_token.application).data,
+        serializers.ApplicationSerializer(token.application).data,
+    ]
+
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == expected
+
+
+def test_grant_delete(factories, logged_in_api_client, mocker, now):
+    token = factories["users.AccessToken"](user=logged_in_api_client.user)
+    refresh_token = factories["users.RefreshToken"](
+        user=logged_in_api_client.user, application=token.application
+    )
+    grant = factories["users.Grant"](
+        user=logged_in_api_client.user, application=token.application
+    )
+    revoke_token = mocker.spy(token.__class__, "revoke")
+    revoke_refresh = mocker.spy(refresh_token.__class__, "revoke")
+    to_keep = [
+        factories["users.AccessToken"](application=token.application),
+        factories["users.RefreshToken"](application=token.application),
+        factories["users.Grant"](application=token.application),
+    ]
+    url = reverse(
+        "api:v1:oauth:grants-detail", kwargs={"client_id": token.application.client_id}
+    )
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+
+    revoke_token.assert_called_once()
+    revoke_refresh.assert_called_once()
+
+    with pytest.raises(token.DoesNotExist):
+        token.refresh_from_db()
+
+    with pytest.raises(grant.DoesNotExist):
+        grant.refresh_from_db()
+
+    refresh_token.refresh_from_db()
+    assert refresh_token.revoked == now
+
+    for t in to_keep:
+        t.refresh_from_db()
diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py
deleted file mode 100644
index 0b92f74a58..0000000000
--- a/api/tests/users/test_permissions.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import pytest
-from rest_framework.views import APIView
-
-from funkwhale_api.users import permissions
-
-
-def test_has_user_permission_no_user(api_request):
-    view = APIView.as_view()
-    permission = permissions.HasUserPermission()
-    request = api_request.get("/")
-    assert permission.has_permission(request, view) is False
-
-
-def test_has_user_permission_anonymous(anonymous_user, api_request):
-    view = APIView.as_view()
-    permission = permissions.HasUserPermission()
-    request = api_request.get("/")
-    setattr(request, "user", anonymous_user)
-    assert permission.has_permission(request, view) is False
-
-
-@pytest.mark.parametrize("value", [True, False])
-def test_has_user_permission_logged_in_single(value, factories, api_request):
-    user = factories["users.User"](permission_moderation=value)
-
-    class View(APIView):
-        required_permissions = ["moderation"]
-
-    view = View()
-    permission = permissions.HasUserPermission()
-    request = api_request.get("/")
-    setattr(request, "user", user)
-    result = permission.has_permission(request, view)
-    assert result == user.has_permissions("moderation") == value
-
-
-@pytest.mark.parametrize(
-    "moderation,library,expected",
-    [
-        (True, False, False),
-        (False, True, False),
-        (False, False, False),
-        (True, True, True),
-    ],
-)
-def test_has_user_permission_logged_in_multiple_and(
-    moderation, library, expected, factories, api_request
-):
-    user = factories["users.User"](
-        permission_moderation=moderation, permission_library=library
-    )
-
-    class View(APIView):
-        required_permissions = ["moderation", "library"]
-        permission_operator = "and"
-
-    view = View()
-    permission = permissions.HasUserPermission()
-    request = api_request.get("/")
-    setattr(request, "user", user)
-    result = permission.has_permission(request, view)
-    assert result == user.has_permissions("moderation", "library") == expected
-
-
-@pytest.mark.parametrize(
-    "moderation,library,expected",
-    [
-        (True, False, True),
-        (False, True, True),
-        (False, False, False),
-        (True, True, True),
-    ],
-)
-def test_has_user_permission_logged_in_multiple_or(
-    moderation, library, expected, factories, api_request
-):
-    user = factories["users.User"](
-        permission_moderation=moderation, permission_library=library
-    )
-
-    class View(APIView):
-        required_permissions = ["moderation", "library"]
-        permission_operator = "or"
-
-    view = View()
-    permission = permissions.HasUserPermission()
-    request = api_request.get("/")
-    setattr(request, "user", user)
-    result = permission.has_permission(request, view)
-    has_permission_result = user.has_permissions("moderation", "library", operator="or")
-
-    assert result == has_permission_result == expected
diff --git a/changes/changelog.d/752.feature b/changes/changelog.d/752.feature
new file mode 100644
index 0000000000..6d33f6faa6
--- /dev/null
+++ b/changes/changelog.d/752.feature
@@ -0,0 +1 @@
+Support OAuth2 authorization for better integration with third-party apps (#752)
diff --git a/changes/notes.rst b/changes/notes.rst
index 19aa5a077c..22e626d961 100644
--- a/changes/notes.rst
+++ b/changes/notes.rst
@@ -20,3 +20,17 @@ Content linked to hidden artists will not show up in the interface anymore. Espe
 - Hidden artists won't appear in Subsonic apps
 
 Results linked to hidden artists will continue to show up in search results and their profile page remains accessible.
+
+OAuth2 authorization for better integration with third-party apps
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Funkwhale now support the OAuth2 authorization and authentication protocol which will allow
+third-party apps to interact with Funkwhale on behalf of users.
+
+This feature makes it possible to build third-party apps that have the same capabilities
+as Funkwhale's Web UI. The only exception at the moment is for actions that requires
+special permissions, such as modifying instance settings or moderation (but this will be
+enabled in a future release).
+
+If you want to start building an app on top of Funkwhale's API, please check-out
+`https://docs.funkwhale.audio/api.html`_ and `https://docs.funkwhale.audio/developers/authentication.html`_.
diff --git a/dev.yml b/dev.yml
index d0ffc62e43..73c67e386f 100644
--- a/dev.yml
+++ b/dev.yml
@@ -138,7 +138,7 @@ services:
       - "8001:8001"
 
   api-docs:
-    image: swaggerapi/swagger-ui
+    image: swaggerapi/swagger-ui:v3.21.0
     environment:
       - "API_URL=/swagger.yml"
     ports:
diff --git a/docs/developers/authentication.rst b/docs/developers/authentication.rst
new file mode 100644
index 0000000000..0d32139c0e
--- /dev/null
+++ b/docs/developers/authentication.rst
@@ -0,0 +1,97 @@
+API Authentication
+==================
+
+Each Funkwhale API endpoint supports access from:
+
+- Anonymous users (if the endpoint is configured to do so, for exemple via the ``API Authentication Required`` setting)
+- Logged-in users
+- Third-party apps (via OAuth2)
+
+To seamlessly support this range of access modes, we internally use oauth scopes
+to describes what permissions are required to perform any given operation.
+
+OAuth
+-----
+
+Create an app
+:::::::::::::
+
+To connect to Funkwhale API via OAuth, you need to create an application. There are
+two ways to do that:
+
+1. By visiting ``/settings/applications/new`` when logged in on your Funkwhale instance.
+2. By sending a ``POST`` request to ``/api/v1/oauth/apps/``, as described in `our API documentation <https://docs.funkwhale.audio/swagger/>`_.
+
+Both method will give you a client ID and secret.
+
+Getting an access token
+:::::::::::::::::::::::
+
+Once you have a client ID and secret, you can request access tokens
+using the `authorization code grant flow <https://tools.ietf.org/html/rfc6749#section-4.1>`_.
+
+We support the ``urn:ietf:wg:oauth:2.0:oob`` redirect URI for non-web applications, as well
+as traditionnal redirection-based flow.
+
+Our authorization endpoint is located at ``/authorize``, and our token endpoint at ``/api/v1/oauth/token/``.
+
+Refreshing tokens
+:::::::::::::::::
+
+When your access token is expired, you can `request a new one as described in the OAuth specification <https://tools.ietf.org/html/rfc6749#section-6>`_.
+
+Security considerations
+:::::::::::::::::::::::
+
+- Grant codes are valid for a 5 minutes after authorization request is approved by the end user.
+- Access codes are valid for 10 hours. When expired, you will need to request a new one using your refresh token.
+- We return a new refresh token everytime an access token is requested, and invalidate the old one. Ensure you store the new refresh token in your app.
+
+
+Scopes
+::::::
+
+Scopes are defined in :file:`funkwhale_api/users/oauth/scopes.py:BASE_SCOPES`, and generally are mapped to a business-logic resources (follows, favorites, etc.). All those base scopes come in two flawours:
+
+- `read:<base_scope>`: get read-only access to the resource
+- `write:<base_scope>`: get write-only access to the ressource
+
+For example, ``playlists`` is a base scope, and ``write:playlists`` is the actual scope needed to perform write
+operations on playlists (via a ``POST``, ``PATCH``, ``PUT`` or ``DELETE``. ``read:playlists`` is used
+to perform read operations on playlists such as fetching a given playlist via ``GET``.
+
+Having the generic ``read`` or ``write`` scope give you the corresponding access on *all* resources.
+
+This is the list of OAuth scopes that third-party applications can request:
+
+
++-------------------------------------------+---------------------------------------------------+
+| Scope                                     | Description                                       |
++===========================================+===================================================+
+| ``read``                                  | Read-only access to all data                      |
+|                                           | (equivalent to all ``read:*`` scopes)             |
++-------------------------------------------+---------------------------------------------------+
+| ``write``                                 | Write-only access to all data                     |
+|                                           | (equivalent to all ``write:*`` scopes)            |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:profile``                  | Access to profile data (email, username, etc.)    |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:libraries``                | Access to library data (uploads, libraries        |
+|                                           | tracks, albums, artists...)                       |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:favorites``                | Access to favorites                               |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:listenings``               | Access to history                                 |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:follows``                  | Access to followers                               |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:playlists``                | Access to playlists                               |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:radios``                   | Access to radios                                  |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:filters``                  | Access to content filters                         |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:notifications``            | Access to notifications                           |
++-------------------------------------------+---------------------------------------------------+
+| ``<read/write>:edits``                    | Access to metadata edits                          |
++-------------------------------------------+---------------------------------------------------+
diff --git a/docs/developers/index.rst b/docs/developers/index.rst
index 69f22f2c6e..966cac3afd 100644
--- a/docs/developers/index.rst
+++ b/docs/developers/index.rst
@@ -12,5 +12,6 @@ Reference
 
    architecture
    ../api
+   ./authentication
    ../federation/index
    subsonic
diff --git a/docs/swagger.yml b/docs/swagger.yml
index de809242d3..a60b233df7 100644
--- a/docs/swagger.yml
+++ b/docs/swagger.yml
@@ -1,13 +1,13 @@
-openapi: "3.0.0"
+openapi: "3.0.2"
 info:
   description: "Documentation for [Funkwhale](https://funkwhale.audio) API. The API is **not** stable yet."
   version: "1.0.0"
   title: "Funkwhale API"
 
 servers:
-  - url: https://demo.funkwhale.audio/api/v1
+  - url: https://demo.funkwhale.audio
     description: Demo server
-  - url: https://{domain}/api/v1
+  - url: https://{domain}
     description: Custom server
     variables:
       domain:
@@ -21,6 +21,38 @@ servers:
 
 components:
   securitySchemes:
+    oauth2:
+      type: oauth2
+      description: This API uses OAuth 2 with the Authorization Code flow. You can register an app using the /oauth/apps/ endpoint.
+      flows:
+        authorizationCode:
+          # Swagger doesn't support relative URLs yet (cf https://github.com/swagger-api/swagger-ui/pull/5244)
+          authorizationUrl: /authorize
+          tokenUrl: /api/v1/oauth/token/
+          refreshUrl: /api/v1/oauth/token/
+          scopes:
+            "read": "Read-only access to all user data"
+            "write": "Write-only access on all user data"
+            "read:profile": "Read-only access to profile data"
+            "read:libraries": "Read-only access to library and uploads"
+            "read:playlists": "Read-only access to playlists"
+            "read:listenings": "Read-only access to listening history"
+            "read:favorites": "Read-only access to favorites"
+            "read:radios": "Read-only access to radios"
+            "read:edits": "Read-only access to edits"
+            "read:notifications": "Read-only access to notifications"
+            "read:follows": "Read-only to follows"
+            "read:filters": "Read-only to to content filters"
+            "write:profile": "Write-only access to profile data"
+            "write:libraries": "Write-only access to libraries"
+            "write:playlists": "Write-only access to playlists"
+            "write:follows": "Write-only access to follows"
+            "write:favorites": "Write-only access to favorits"
+            "write:notifications": "Write-only access to notifications"
+            "write:radios": "Write-only access to radios"
+            "write:edits": "Write-only access to edits"
+            "write:filters": "Write-only access to content-filters"
+            "write:listenings": "Write-only access to listening history"
     jwt:
       type: http
       scheme: bearer
@@ -29,9 +61,44 @@ components:
 
 security:
   - jwt: []
+  - oauth2: []
 
 paths:
-  /token/:
+  /api/v1/oauth/apps/:
+    post:
+      tags:
+        - "auth"
+      description:
+        Register an OAuth application
+      security: []
+      responses:
+        201:
+          content:
+            application/json:
+              schema:
+                allOf:
+                  - $ref: "#/definitions/OAuthApplication"
+                  - $ref: "#/definitions/OAuthApplicationCreation"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: "object"
+              properties:
+                name:
+                  type: "string"
+                  example: "My Awesome Funkwhale Client"
+                  summary: "A human readable name for your app"
+                redirect_uris:
+                  type: "string"
+                  example: "https://myapp/oauth2/funkwhale"
+                  summary: "A list of redirect uris, separated by spaces"
+                scopes:
+                  type: "string"
+                  summary: "A list of scopes requested by your app, separated by spaces"
+                  example: "read write:playlists write:favorites"
+  /api/v1/token/:
     post:
       tags:
         - "auth"
@@ -57,11 +124,14 @@ paths:
                   type: "string"
                   example: "demo"
 
-  /artists/:
+  /api/v1/artists/:
     get:
       summary: List artists
       tags:
         - "artists"
+      security:
+        - oauth2:
+          - "read:libraries"
       parameters:
         - name: "q"
           in: "query"
@@ -99,12 +169,14 @@ paths:
                         type: "array"
                         items:
                           $ref: "#/definitions/Artist"
-  /artists/{id}/:
+  /api/v1/artists/{id}/:
     get:
       summary: Retrieve a single artist
       parameters:
         - $ref: "#/parameters/ObjectId"
-
+      security:
+        - oauth2:
+          - "read:libraries"
       tags:
         - "artists"
       responses:
@@ -118,9 +190,12 @@ paths:
             application/json:
               schema:
                 $ref: "#/definitions/ResourceNotFound"
-  /artists/{id}/libraries/:
+  /api/v1/artists/{id}/libraries/:
     get:
       summary: List available user libraries containing work from this artist
+      security:
+        - oauth2:
+          - "read:libraries"
       parameters:
         - $ref: "#/parameters/ObjectId"
         - $ref: "#/parameters/PageNumber"
@@ -141,11 +216,15 @@ paths:
               schema:
                 $ref: "#/definitions/ResourceNotFound"
 
-  /albums/:
+  /api/v1/albums/:
     get:
       summary: List albums
       tags:
         - "albums"
+
+      security:
+        - oauth2:
+          - "read:libraries"
       parameters:
         - name: "q"
           in: "query"
@@ -191,12 +270,15 @@ paths:
                         type: "array"
                         items:
                           $ref: "#/definitions/Album"
-  /albums/{id}/:
+  /api/v1/albums/{id}/:
     get:
       summary: Retrieve a single album
       parameters:
         - $ref: "#/parameters/ObjectId"
 
+      security:
+        - oauth2:
+          - "read:libraries"
       tags:
         - "albums"
       responses:
@@ -211,7 +293,7 @@ paths:
               schema:
                 $ref: "#/definitions/ResourceNotFound"
 
-  /albums/{id}/libraries/:
+  /api/v1/albums/{id}/libraries/:
     get:
       summary: List available user libraries containing tracks from this album
       parameters:
@@ -219,6 +301,9 @@ paths:
         - $ref: "#/parameters/PageNumber"
         - $ref: "#/parameters/PageSize"
 
+      security:
+        - oauth2:
+          - "read:libraries"
       tags:
         - "albums"
         - "libraries"
@@ -234,11 +319,15 @@ paths:
               schema:
                 $ref: "#/definitions/ResourceNotFound"
 
-  /tracks/:
+  /api/v1/tracks/:
     get:
       summary: List tracks
       tags:
         - "tracks"
+
+      security:
+        - oauth2:
+          - "read:libraries"
       parameters:
         - name: "q"
           in: "query"
@@ -300,12 +389,15 @@ paths:
                         type: "array"
                         items:
                           $ref: "#/definitions/Track"
-  /tracks/{id}/:
+  /api/v1/tracks/{id}/:
     get:
       summary: Retrieve a single track
       parameters:
         - $ref: "#/parameters/ObjectId"
 
+      security:
+        - oauth2:
+          - "read:libraries"
       tags:
         - "tracks"
       responses:
@@ -320,14 +412,16 @@ paths:
               schema:
                 $ref: "#/definitions/ResourceNotFound"
 
-  /tracks/{id}/libraries/:
+  /api/v1/tracks/{id}/libraries/:
     get:
       summary: List available user libraries containing given track
       parameters:
         - $ref: "#/parameters/ObjectId"
         - $ref: "#/parameters/PageNumber"
         - $ref: "#/parameters/PageSize"
-
+      security:
+        - oauth2:
+          - "read:libraries"
       tags:
         - "tracks"
         - "libraries"
@@ -343,9 +437,12 @@ paths:
               schema:
                 $ref: "#/definitions/ResourceNotFound"
 
-  /licenses/:
+  /api/v1/licenses/:
     get:
       summary: List licenses
+      security:
+        - oauth2:
+          - "read:libraries"
       tags:
         - "licenses"
       parameters:
@@ -365,9 +462,12 @@ paths:
                         items:
                           $ref: "#/definitions/License"
 
-  /licenses/{code}/:
+  /api/v1/licenses/{code}/:
     get:
       summary: Retrieve a single license
+      security:
+        - oauth2:
+          - "read:libraries"
       parameters:
         - name: code
           in: path
@@ -441,6 +541,34 @@ properties:
     description: "A musicbrainz ID"
 
 definitions:
+  OAuthApplication:
+    type: "object"
+    properties:
+      client_id:
+        type: "string"
+        example: "VKIZWv7FwBq56UMfUtbCSIgSxzUTv1b6nMyOkJvP"
+      created:
+        type: "string"
+        format: "date-time"
+      updated:
+        type: "string"
+        format: "date-time"
+      scopes:
+        type: "string"
+        description: "Coma-separated list of scopes requested by the app"
+
+  OAuthApplicationCreation:
+    type: "object"
+    properties:
+      client_secret:
+        type: "string"
+        example: "qnKDX8zjIfC0BG4tUreKlqk3tNtuCfJdGsaEt5MIWrTv0YLLhGI6SGqCjs9kn12gyXtIg4FWfZqWMEckJmolCi7a6qew4LawPWMfnLDii4mQlY1eQG4BJbwPANOrDiTZ"
+      redirect_uris:
+        type: "string"
+        format: "uri"
+        description: "Coma-separated list of redirect uris allowed for the app"
+
+
   ResultPage:
     type: "object"
     properties:
diff --git a/front/src/components/auth/ApplicationEdit.vue b/front/src/components/auth/ApplicationEdit.vue
new file mode 100644
index 0000000000..b22ade7af6
--- /dev/null
+++ b/front/src/components/auth/ApplicationEdit.vue
@@ -0,0 +1,80 @@
+<template>
+  <main class="main pusher" v-title="labels.title">
+    <div class="ui vertical stripe segment">
+      <section class="ui text container">
+        <div v-if="isLoading" class="ui inverted active dimmer">
+          <div class="ui loader"></div>
+        </div>
+        <template v-else>
+          <router-link :to="{name: 'settings'}">
+            <translate translate-context="Content/Applications/Link">Back to settings</translate>
+          </router-link>
+          <h2 class="ui header">
+            <translate translate-context="Content/Applications/Title">Application details</translate>
+          </h2>
+          <div class="ui form">
+            <p>
+              <translate translate-context="Content/Application/Paragraph/">
+                Application ID and secret are really sensitive values and must be treated like passwords. Do not share those with anyone else.
+              </translate>
+            </p>
+            <div class="field">
+              <label><translate translate-context="Content/Applications/Label">Application ID</translate></label>
+              <copy-input :value="application.client_id" />
+            </div>
+            <div class="field">
+              <label><translate translate-context="Content/Applications/Label">Application secret</translate></label>
+              <copy-input :value="application.client_secret" />
+            </div>
+          </div>
+          <h2 class="ui header">
+            <translate translate-context="Content/Applications/Title">Edit application</translate>
+          </h2>
+          <application-form @updated="application = $event" :app="application" />
+        </template>
+      </section>
+    </div>
+  </main>
+</template>
+
+<script>
+import axios from "axios"
+
+import ApplicationForm from "@/components/auth/ApplicationForm"
+
+export default {
+  props: ['id'],
+  components: {
+    ApplicationForm
+  },
+  data() {
+    return {
+      application: null,
+      isLoading: false,
+    }
+  },
+  created () {
+    this.fetchApplication()
+  },
+  methods: {
+    fetchApplication () {
+      this.isLoading = true
+      let self = this
+      axios.get(`oauth/apps/${this.id}/`).then((response) => {
+        self.isLoading = false
+        self.application = response.data
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+  },
+  computed: {
+    labels() {
+      return {
+        title: this.$pgettext('Content/Applications/Title', "Edit application")
+      }
+    },
+  }
+}
+</script>
diff --git a/front/src/components/auth/ApplicationForm.vue b/front/src/components/auth/ApplicationForm.vue
new file mode 100644
index 0000000000..f89d0693a1
--- /dev/null
+++ b/front/src/components/auth/ApplicationForm.vue
@@ -0,0 +1,183 @@
+<template>
+
+  <form class="ui form" @submit.prevent="submit()">
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate translate-context="Content/*/Error message.Title">We cannot save your changes</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div class="ui field">
+      <label><translate translate-context="Content/Applications/Input.Label/Noun">Name</translate></label>
+      <input name="name" required type="text" v-model="fields.name" />
+    </div>
+    <div class="ui field">
+      <label><translate translate-context="Content/Applications/Input.Label/Noun">Redirect URI</translate></label>
+      <input name="redirect_uris" type="text" v-model="fields.redirect_uris" />
+      <p class="help">
+        <translate translate-context="Content/Applications/Help Text">
+          Use "urn:ietf:wg:oauth:2.0:oob" as a redirect URI if your application is not served on the web.
+        </translate>
+      </p>
+    </div>
+    <div class="ui field">
+      <label><translate translate-context="Content/Applications/Input.Label/Noun">Scopes</translate></label>
+      <p>
+        <translate translate-context="Content/Applications/Paragraph/">
+          Checking the parent "Read" or "Write" scopes implies access to all the corresponding children scopes.
+        </translate>
+      </p>
+      <div class="ui stackable two column grid">
+        <div v-for="parent in allScopes" class="column">
+          <div class="ui parent checkbox">
+            <input
+              v-model="scopeArray"
+              :value="parent.id"
+              :id="parent.id"
+              type="checkbox">
+            <label :for="parent.id">
+              {{ parent.label }}
+              <p class="help">
+                {{ parent.description }}
+              </p>
+            </label>
+          </div>
+
+          <div v-for="child in parent.children">
+            <div class="ui child checkbox">
+              <input
+                v-model="scopeArray"
+                :value="child.id"
+                :id="child.id"
+                type="checkbox">
+              <label :for="child.id">
+                {{ child.id }}
+                <p class="help">
+                  {{ child.description }}
+                </p>
+              </label>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      </div>
+    <button :class="['ui', {'loading': isLoading}, 'green', 'button']" type="submit">
+      <translate v-if="updating" key="2" translate-context="Content/Applications/Button.Label/Verb">Update application</translate>
+      <translate v-else key="2" translate-context="Content/Applications/Button.Label/Verb">Create application</translate>
+    </button>
+  </form>
+</template>
+
+<script>
+import lodash from "@/lodash"
+import axios from "axios"
+import TranslationsMixin from "@/components/mixins/Translations"
+
+export default {
+  mixins: [TranslationsMixin],
+  props: {
+    app: {type: Object, required: false}
+  },
+  data() {
+    let app = this.app || {}
+    return {
+      isLoading: false,
+      errors: [],
+      fields: {
+        name: app.name || '',
+        redirect_uris: app.redirect_uris || 'urn:ietf:wg:oauth:2.0:oob',
+        scopes: app.scopes || 'read'
+      },
+      scopes: [
+        {id: "profile", icon: 'user'},
+        {id: "libraries", icon: 'book'},
+        {id: "favorites", icon: 'heart'},
+        {id: "listenings", icon: 'music'},
+        {id: "follows", icon: 'users'},
+        {id: "playlists", icon: 'list'},
+        {id: "radios", icon: 'rss'},
+        {id: "filters", icon: 'eye slash'},
+        {id: "notifications", icon: 'bell'},
+        {id: "edits", icon: 'pencil alternate'},
+      ]
+    }
+  },
+  methods: {
+    submit () {
+      this.errors = []
+      let self = this
+      self.isLoading = true
+      let payload = this.fields
+      let event, promise, message
+      if (this.updating) {
+        event = 'updated'
+        promise = axios.patch(`oauth/apps/${this.app.client_id}/`, payload)
+      }  else {
+        event = 'created'
+        promise = axios.post(`oauth/apps/`, payload)
+      }
+      return promise.then(
+        response => {
+          self.isLoading = false
+          self.$emit(event, response.data)
+        },
+        error => {
+          self.isLoading = false
+          self.errors = error.backendErrors
+        }
+      )
+    },
+  },
+  computed: {
+    updating () {
+      return this.app
+    },
+    scopeArray: {
+      get () {
+        return this.fields.scopes.split(' ')
+      },
+      set (v) {
+        this.fields.scopes = _.uniq(v).join(' ')
+      }
+    },
+    allScopes () {
+      let self = this
+      let parents = [
+        {
+          id: 'read',
+          label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Read'),
+          description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Read-only access to user data'),
+          value: this.scopeArray.indexOf('read') > -1
+        },
+        {
+          id: 'write',
+          label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Write'),
+          description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'),
+          value: this.scopeArray.indexOf('write') > -1
+        },
+      ]
+      parents.forEach((p) => {
+        p.children = self.scopes.map(s => {
+          let id = `${p.id}:${s.id}`
+          return {
+            id,
+            value: this.scopeArray.indexOf(id) > -1,
+          }
+        })
+      })
+      return parents
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+.parent.checkbox {
+  margin: 1em 0;
+}
+.child.checkbox {
+  margin-left: 1em;
+}
+</style>
diff --git a/front/src/components/auth/ApplicationNew.vue b/front/src/components/auth/ApplicationNew.vue
new file mode 100644
index 0000000000..8bb36826ce
--- /dev/null
+++ b/front/src/components/auth/ApplicationNew.vue
@@ -0,0 +1,39 @@
+<template>
+  <main class="main pusher" v-title="labels.title">
+    <div class="ui vertical stripe segment">
+      <section class="ui text container">
+        <router-link :to="{name: 'settings'}">
+          <translate translate-context="Content/Applications/Link">Back to settings</translate>
+        </router-link>
+        <h2 class="ui header">
+          <translate translate-context="Content/Applications/Title">Create a new application</translate>
+        </h2>
+        <application-form
+          @created="$router.push({name: 'settings.applications.edit', params: {id: $event.client_id}})" />
+      </section>
+    </div>
+  </main>
+</template>
+
+<script>
+import ApplicationForm from "@/components/auth/ApplicationForm"
+
+export default {
+  components: {
+    ApplicationForm
+  },
+   data() {
+    return {
+      application: null,
+      isLoading: false,
+    }
+  },
+  computed: {
+    labels() {
+      return {
+        title: this.$pgettext('Content/Applications/Title', "Create a new application")
+      }
+    },
+  }
+}
+</script>
diff --git a/front/src/components/auth/Authorize.vue b/front/src/components/auth/Authorize.vue
new file mode 100644
index 0000000000..dd9bf6eebf
--- /dev/null
+++ b/front/src/components/auth/Authorize.vue
@@ -0,0 +1,201 @@
+<template>
+  <main class="main pusher" v-title="labels.title">
+    <section class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2><i class="lock open icon"></i><translate translate-context="Content/Auth/Title/Verb">Authorize third-party app</translate></h2>
+        <div v-if="errors.length > 0" class="ui negative message">
+          <div v-if="application" class="header"><translate translate-context="Popup/Moderation/Error message">Error while authorizing application</translate></div>
+          <div v-else class="header"><translate translate-context="Popup/Moderation/Error message">Error while fetching application data</translate></div>
+          <ul class="list">
+            <li v-for="error in errors">{{ error }}</li>
+          </ul>
+        </div>
+        <div v-if="isLoading" class="ui inverted active dimmer">
+          <div class="ui loader"></div>
+        </div>
+        <form v-else-if="application && !code" :class="['ui', {loading: isLoading}, 'form']" @submit.prevent="submit">
+          <h3><translate translate-context="Content/Auth/Title" :translate-params="{app: application.name}">%{ app } wants to access your Funkwhale account</translate></h3>
+
+          <h4 v-for="topic in topicScopes" class="ui header">
+            <span v-if="topic.write && !topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'label']">
+              <i class="pencil icon"></i>
+              <translate translate-context="Content/Auth/Label/Noun">Write-only</translate>
+            </span>
+            <span v-else-if="!topic.write && topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'label']">
+              <translate translate-context="Content/Auth/Label/Noun">Read-only</translate>
+            </span>
+            <span v-else-if="topic.write && topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'label']">
+              <i class="pencil icon"></i>
+              <translate translate-context="Content/Auth/Label/Noun">Full access</translate>
+            </span>
+            <i :class="[topic.icon, 'icon']"></i>
+            <div class="content">
+              {{ topic.label }}
+              <div class="sub header">
+                {{ topic.description }}
+              </div>
+            </div>
+          </h4>
+          <div v-if="unknownRequestedScopes.length > 0">
+            <p><strong><translate translate-context="Content/Auth/Paragraph">The application is also requesting the following unknown permissions:</translate></strong></p>
+            <ul v-for="scope in unknownRequestedScopes">
+              <li>{{ scope }}</li>
+            </ul>
+
+          </div>
+          <button class="ui green labeled icon button" type="submit">
+            <i class="lock open icon"></i>
+            <translate translate-context="Content/Signup/Button.Label/Verb" :translate-params="{app: application.name}">Authorize %{ app }</translate>
+          </button>
+          <p v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'" key="1" v-translate translate-context="Content/Auth/Paragraph">
+            You will be shown a code to copy-paste in the application.</p>
+          <p v-else key="2" v-translate="{url: redirectUri}" translate-context="Content/Auth/Paragraph" :translate-params="{url: redirectUri}">You will be redirected to <strong>%{ url }</strong></p>
+
+        </form>
+        <div v-else-if="code">
+          <p><strong><translate translate-context="Content/Auth/Paragraph">Copy-paste the following code in the application:</translate></strong></p>
+          <copy-input :value="code"></copy-input>
+        </div>
+      </div>
+    </section>
+  </main>
+</template>
+
+<script>
+import TranslationsMixin from "@/components/mixins/Translations"
+
+import axios from 'axios'
+
+export default {
+  mixins: [TranslationsMixin],
+  props: [
+    'clientId',
+    'redirectUri',
+    'scope',
+    'responseType',
+    'nonce',
+    'state',
+  ],
+  data() {
+    return {
+      application: null,
+      isLoading: false,
+      errors: [],
+      code: null,
+      knownScopes: [
+        {id: "profile", icon: 'user'},
+        {id: "libraries", icon: 'book'},
+        {id: "favorites", icon: 'heart'},
+        {id: "listenings", icon: 'music'},
+        {id: "follows", icon: 'users'},
+        {id: "playlists", icon: 'list'},
+        {id: "radios", icon: 'rss'},
+        {id: "filters", icon: 'eye slash'},
+        {id: "notifications", icon: 'bell'},
+        {id: "edits", icon: 'pencil alternate'},
+      ]
+    }
+  },
+  created () {
+    if (this.clientId) {
+      this.fetchApplication()
+    }
+  },
+  computed: {
+    labels () {
+      return {
+        title: this.$pgettext('Head/Authorize/Title', "Allow application")
+      }
+    },
+    requestedScopes () {
+      return (this.scope || '').split(' ')
+    },
+    supportedScopes () {
+      let supported = ['read', 'write']
+      this.knownScopes.forEach(s => {
+        supported.push(`read:${s.id}`)
+        supported.push(`write:${s.id}`)
+      })
+      return supported
+    },
+    unknownRequestedScopes () {
+      let self = this
+      return this.requestedScopes.filter(s => {
+        return self.supportedScopes.indexOf(s) < 0
+      })
+    },
+    topicScopes () {
+      let self = this
+      let requested = this.requestedScopes
+      let write = false
+      let read = false
+      if (requested.indexOf('read') > -1) {
+        read = true
+      }
+      if (requested.indexOf('write') > -1) {
+        write = true
+      }
+
+      return this.knownScopes.map(s => {
+        let id = s.id
+        return {
+          id: id,
+          icon: s.icon,
+          label: self.sharedLabels.scopes[s.id].label,
+          description: self.sharedLabels.scopes[s.id].description,
+          read: read || requested.indexOf(`read:${id}`) > -1,
+          write: write || requested.indexOf(`write:${id}`) > -1,
+        }
+      }).filter(c => {
+        return c.read || c.write
+      })
+    }
+  },
+  methods: {
+    fetchApplication () {
+      this.isLoading = true
+      let self = this
+      axios.get(`oauth/apps/${this.clientId}/`).then((response) => {
+        self.isLoading = false
+        self.application = response.data
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    submit () {
+      this.isLoading = true
+      let self = this
+      let data = new FormData();
+      data.set('redirect_uri', this.redirectUri)
+      data.set('scope', this.scope)
+      data.set('allow', true)
+      data.set('client_id', this.clientId)
+      data.set('response_type', this.responseType)
+      data.set('state', this.state)
+      data.set('nonce', this.nonce)
+      axios.post(`oauth/authorize/`, data, {headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'}}).then((response) => {
+        if (self.redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
+          self.isLoading = false
+          self.code = response.data.code
+        } else {
+          window.location.href = response.data.redirect_uri
+        }
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+.ui.header .content {
+  text-align: left;
+}
+.ui.header > .ui.label {
+  margin-top: 0.3em;
+}
+</style>
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 8f62655d35..6e2516ed87 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -1,7 +1,7 @@
 <template>
   <main class="main pusher" v-title="labels.title">
     <div class="ui vertical stripe segment">
-      <section class="ui small text container">
+      <section class="ui text container">
         <h2 class="ui header">
           <translate translate-context="Content/Settings/Title">Account settings</translate>
         </h2>
@@ -29,7 +29,7 @@
           </button>
         </form>
       </section>
-      <section class="ui small text container">
+      <section class="ui text container">
         <div class="ui hidden divider"></div>
         <h2 class="ui header">
           <translate translate-context="Content/Settings/Title">Avatar</translate>
@@ -63,7 +63,7 @@
         </div>
       </section>
 
-      <section class="ui small text container">
+      <section class="ui text container">
         <div class="ui hidden divider"></div>
         <h2 class="ui header">
           <translate translate-context="Content/Settings/Title/Verb">Change my password</translate>
@@ -109,7 +109,7 @@
         <subsonic-token-form />
       </section>
 
-      <section class="ui small text container" id="content-filters">
+      <section class="ui text container" id="content-filters">
         <div class="ui hidden divider"></div>
         <h2 class="ui header">
           <i class="eye slash outline icon"></i>
@@ -155,6 +155,118 @@
           </tbody>
         </table>
       </section>
+      <section class="ui text container" id="grants">
+        <div class="ui hidden divider"></div>
+        <h2 class="ui header">
+          <i class="open lock icon"></i>
+          <div class="content">
+            <translate translate-context="Content/Settings/Title/Noun">Authorized apps</translate>
+          </div>
+        </h2>
+        <p><translate translate-context="Content/Settings/Paragraph">This is the list of applications that have access to your account data.</translate></p>
+        <button
+          @click="fetchApps()"
+          class="ui basic icon button">
+          <i class="refresh icon"></i>&nbsp;
+          <translate translate-context="Content/*/Button.Label/Short, Verb">Refresh</translate>
+        </button>
+        <table v-if="apps.length > 0" class="ui compact very basic unstackable table">
+          <thead>
+            <tr>
+              <th><translate translate-context="*/*/*/Noun">Application</translate></th>
+              <th><translate translate-context="Content/*/*/Noun">Permissions</translate></th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="app in apps" :key='app.client_id'>
+              <td>
+                {{ app.name }}
+              </td>
+              <td>
+                {{ app.scopes }}
+              </td>
+              <td>
+                <dangerous-button
+                  class="ui tiny basic button"
+                  @confirm="revokeApp(app.client_id)">
+                  <translate translate-context="*/*/*/Verb">Revoke</translate>
+                  <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Revoke access for application "%{ application }"?</p>
+                  <p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will prevent this application from accessing the service on your behalf.</translate></p>
+                  <div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Revoke access</translate></div>
+                </dangerous-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <empty-state v-else>
+          <translate slot="title" translate-context="Content/Applications/Paragraph">
+            You don't have any application connected with your account.
+          </translate>
+          <translate translate-context="Content/Applications/Paragraph">
+            If you authorize third-party applications to access your data, those applications will be listed here.
+          </translate>
+        </empty-state>
+      </section>
+      <section class="ui text container" id="apps">
+        <div class="ui hidden divider"></div>
+        <h2 class="ui header">
+          <i class="code icon"></i>
+          <div class="content">
+            <translate translate-context="Content/Settings/Title/Noun">Your applications</translate>
+          </div>
+        </h2>
+        <p><translate translate-context="Content/Settings/Paragraph">This is the list of applications that you have created.</translate></p>
+        <router-link class="ui basic green button" :to="{name: 'settings.applications.new'}">
+          <translate translate-context="Content/Settings/Button.Label">Create a new application</translate>
+        </router-link>
+        <table v-if="ownedApps.length > 0" class="ui compact very basic unstackable table">
+          <thead>
+            <tr>
+              <th><translate translate-context="*/*/*/Noun">Application</translate></th>
+              <th><translate translate-context="Content/*/*/Noun">Scopes</translate></th>
+              <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="app in ownedApps" :key='app.client_id'>
+              <td>
+                <router-link :to="{name: 'settings.applications.edit', params: {id: app.client_id}}">
+                  {{ app.name }}
+                </router-link>
+              </td>
+              <td>
+                {{ app.scopes }}
+              </td>
+              <td>
+                <human-date :date="app.created" />
+              </td>
+              <td>
+                <router-link class="ui basic tiny green button" :to="{name: 'settings.applications.edit', params: {id: app.client_id}}">
+                  <translate translate-context="Content/Settings/Button.Label">Edit</translate>
+                </router-link>
+                <dangerous-button
+                  class="ui tiny basic button"
+                  @confirm="deleteApp(app.client_id)">
+                  <translate translate-context="*/*/*/Verb">Delete</translate>
+                  <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Delete application "%{ application }"?</p>
+                  <p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will permanently delete the application and all the associated tokens.</translate></p>
+                  <div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Delete application</translate></div>
+                </dangerous-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <empty-state v-else>
+          <translate slot="title" translate-context="Content/Applications/Paragraph">
+            You don't have any configured application yet.
+          </translate>
+          <translate translate-context="Content/Applications/Paragraph">
+            Create one to integrate Funkwhale with third-party applications.
+          </translate>
+        </empty-state>
+      </section>
     </div>
   </main>
 </template>
@@ -185,6 +297,8 @@ export default {
       isLoadingAvatar: false,
       avatarErrors: [],
       avatar: null,
+      apps: [],
+      ownedApps: [],
       settings: {
         success: false,
         errors: [],
@@ -204,6 +318,10 @@ export default {
     })
     return d
   },
+  created () {
+    this.fetchApps()
+    this.fetchOwnedApps()
+  },
   mounted() {
     $("select.dropdown").dropdown()
   },
@@ -229,6 +347,56 @@ export default {
         }
       )
     },
+    fetchApps() {
+      this.apps = []
+      let self = this
+      let url = `oauth/grants/`
+      return axios.get(url).then(
+        response => {
+          self.apps = response.data
+        },
+        error => {
+        }
+      )
+    },
+    fetchOwnedApps() {
+      this.ownedApps = []
+      let self = this
+      let url = `oauth/apps/`
+      return axios.get(url).then(
+        response => {
+          self.ownedApps = response.data.results
+        },
+        error => {
+        }
+      )
+    },
+    revokeApp (id) {
+      let self = this
+      let url = `oauth/grants/${id}/`
+      return axios.delete(url).then(
+        response => {
+          self.apps = self.apps.filter(a => {
+            return a.client_id != id
+          })
+        },
+        error => {
+        }
+      )
+    },
+    deleteApp (id) {
+      let self = this
+      let url = `oauth/apps/${id}/`
+      return axios.delete(url).then(
+        response => {
+          self.ownedApps = self.ownedApps.filter(a => {
+            return a.client_id != id
+          })
+        },
+        error => {
+        }
+      )
+    },
     submitAvatar() {
       this.isLoadingAvatar = true
       this.avatarErrors = []
diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue
index 75bbaef205..2bbc64c83f 100644
--- a/front/src/components/mixins/Translations.vue
+++ b/front/src/components/mixins/Translations.vue
@@ -35,6 +35,48 @@ export default {
           received_messages: this.$pgettext('Content/Moderation/*/Noun', 'Received messages'),
           uploads: this.$pgettext('Content/Moderation/Table.Label/Noun', 'Uploads'),
           followers: this.$pgettext('Content/Federation/*/Noun', 'Followers'),
+        },
+        scopes: {
+          profile: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Profile'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to email, username, and profile information'),
+          },
+          libraries: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Libraries and uploads'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to audio files, libraries, artists, albums and tracks'),
+          },
+          favorites: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Favorites'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to favorites'),
+          },
+          listenings: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Listenings'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to listening history'),
+          },
+          follows: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Follows'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to follows'),
+          },
+          playlists: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Playlists'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to playlists'),
+          },
+          radios: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Radios'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to radios'),
+          },
+          filters: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Content filters'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to content filters'),
+          },
+          notifications: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Notifications'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to notifications'),
+          },
+          edits: {
+            label: this.$pgettext('Content/OAuth Scopes/Label', 'Edits'),
+            description: this.$pgettext('Content/OAuth Scopes/Paragraph', 'Access to edits'),
+          }
         }
       }
     }
diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue
index fec8fdd059..75cc97a888 100644
--- a/front/src/components/semantic/Modal.vue
+++ b/front/src/components/semantic/Modal.vue
@@ -21,7 +21,7 @@ export default {
   },
   beforeDestroy () {
     if (this.control) {
-      this.control.remove()
+      $(this.$el).modal('hide')
     }
   },
   methods: {
@@ -61,5 +61,4 @@ export default {
 
 <!-- Add "scoped" attribute to limit CSS to this component only -->
 <style scoped lang="scss">
-
 </style>
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 9320b74b81..8703eedead 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -3,10 +3,13 @@ import Router from 'vue-router'
 import PageNotFound from '@/components/PageNotFound'
 import About from '@/components/About'
 import Home from '@/components/Home'
+import Authorize from '@/components/auth/Authorize'
 import Login from '@/components/auth/Login'
 import Signup from '@/components/auth/Signup'
 import Profile from '@/components/auth/Profile'
 import Settings from '@/components/auth/Settings'
+import ApplicationNew from '@/components/auth/ApplicationNew'
+import ApplicationEdit from '@/components/auth/ApplicationEdit'
 import Logout from '@/components/auth/Logout'
 import PasswordReset from '@/views/auth/PasswordReset'
 import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm'
@@ -104,6 +107,19 @@ export default new Router({
         defaultToken: route.query.token
       })
     },
+    {
+      path: '/authorize',
+      name: 'authorize',
+      component: Authorize,
+      props: (route) => ({
+        clientId: route.query.client_id,
+        redirectUri: route.query.redirect_uri,
+        scope: route.query.scope,
+        responseType: route.query.response_type,
+        nonce: route.query.nonce,
+        state: route.query.state,
+      })
+    },
     {
       path: '/signup',
       name: 'signup',
@@ -122,6 +138,17 @@ export default new Router({
       name: 'settings',
       component: Settings
     },
+    {
+      path: '/settings/applications/new',
+      name: 'settings.applications.new',
+      component: ApplicationNew
+    },
+    {
+      path: '/settings/applications/:id/edit',
+      name: 'settings.applications.edit',
+      component: ApplicationEdit,
+      props: true
+    },
     {
       path: '/@:username',
       name: 'profile',
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index e436dc3373..b5f1ac4a45 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -330,3 +330,11 @@ td.align.right {
 .card .description {
   word-wrap: break-word;
 }
+
+.ui.checkbox label {
+  cursor: pointer;
+}
+
+input + .help {
+  margin-top: 0.5em;
+}
-- 
GitLab