Scopes

In addition to protecting routes to authenticated users, they can be scoped to require one or more scopes by applying the @scoped() decorator. This means that only users with a particular scope can access a particular endpoint.

Note

If you are using the @scoped decorator, you do NOT also need the @protected decorator. It is assumed that if you are scoping the endpoint, that it is also meant to be protected.


Requirements for a scoped

A scope is a string that consists of two parts:

  • namespace
  • actions

For example, it might look like this: user:read.

namespace - A scope can have either one namespace, or no namespaces
action - A scope can have either no actions, or many actions

Example Scopes

Example #1
scope:       user
namespace:   user
action:      --

Example #2
scope:       user:read
namespace:   user
action:      read

Example #3
scope:       user:read:write
namespace:   user
action:      [read, write]

Example #4
scope:       :read
namespace:   --
action:      read

Example #5
scope:       :read:write
namespace:   --
action:      [read, write]

How are scopes accepted?

In defining a scoped route, you define one or more scopes that will be acceptable.

A scope is accepted if the payload contains a scope that is equal to or higher than what is required.

For sake of clarity in the below explanation, required_scope means the scope that is required for access, and user_scope is the scope that the access token has in its payload.

A scope is acceptable …

  • If the required_scope namespace and the user_scope namespace are equal

    # True
    required_scope = 'user'
    user_scope = 'user'
    
  • If the required_scope has actions, then the user_scope must be:
    • top level (no defined actions), or
    • also has the same actions
    # True
    required_scope = 'user:read'
    user_scope = 'user'
    
    # True
    required_scope = 'user:read'
    user_scope = 'user:read'
    
    # True
    required_scope = 'user:read'
    user_scope = 'user:read:write'
    
    # True
    required_scope = ':read'
    user_scope = ':read'
    
    # False
    required_scope = 'user:write'
    user_scope = 'user:read'
    

Examples

Here is a list of example scopes and whether they pass or not:

required scope      user scope(s)                    outcome
==============      =============                    =======
'user'              ['something']                    False
'user'              ['user']                         True
'user:read'         ['user']                         True
'user:read'         ['user:read']                    True
'user:read'         ['user:write']                   False
'user:read'         ['user:read:write']              True
'user'              ['user:read']                    False
'user:read:write'   ['user:read']                    False
'user:read:write'   ['user:read:write']              True
'user:read:write'   ['user:write:read']              True
'user'              ['something', 'else']            False
'user'              ['something', 'else', 'user']    True
'user:read'         ['something:else', 'user:read']  True
'user:read'         ['user:read', 'something:else']  True
':read'             [':read']                        True
':read'             ['admin']                        True

The @scoped decorator

Basics

In order to protect a route from being accessed by tokens without the appropriate scope(s), pass in one or more scopes:

@app.route("/protected/scoped/1")
@scoped('user')
async def protected_route1(request):
    return json({"protected": True, "scoped": True})

In the above example, only an access token with a payload containing a scope for user will be accepted (such as the payload below).

{
    "user_id": 1,
    "scopes: ["user"]
}

You can also define multiple scopes:

@scoped(['user', 'admin'])

In the above example with a ['user', 'admin'] scope, a payload MUST contain both user and admin.

But, what if we only want to require one of the scopes, and not both user AND admin? Easy:

@scoped(['user', 'admin'], False)

Now, having a scope of either user OR admin will be acceptable.

If you have initialized Sanic JWT on a Blueprint, then you will need to pass the instance of that blueprint into the @scoped decorator.

bp = Blueprint('Users')
Initialize(bp)

@bp.get('/users/<id>')
@scoped(['user', 'admin'], initialized_on=bp)
async def users(request, id):
    ...

Note

If you provide a False or None value to the @scoped decorator, it will effectively remove all protection. This means all requests, whether authenticated or not, will be accepted.

Parameters

The @scoped() decorator takes three parameters:

  • scopes
  • requires_all - default True
  • require_all_actions - default True

scopes - Required

Either a single string, or a list of strings that are the defined scopes for the route. Or, a callable or awaitable that returns the same.

@scoped('user')
...

Or

@scoped(['user', 'admin'])
...

Or

def get_some_scopes(request, *args, **kwargs):
    return ['user', 'admin']

@scoped(get_some_scopes)
...

Or

async def get_some_scopes(request, *args, **kwargs):
    return await something_that_returns_scopes()

@scoped(get_some_scopes)
...

require_all - Optional

A boolean that determines whether all of the defined scopes, or just one must be satisfied. Defaults to True.

@scoped(['user', 'admin'])
...
# A payload MUST have both 'user' and 'admin' scopes


@scoped(['user', 'admin'], require_all=False)
...
# A payload can have either 'user' or 'admin' scope

require_all_actions - Optional

A boolean that determines whether all of the actions on a defined scope, or just one must be satisfied. Defaults to True.

@scoped(':read:write')
...
# A payload MUST have both the `:read` and `:write` actions in scope


@scoped(':read:write', require_all_actions=False)
...
# A payload can have either the `:read` or `:write` action in scope

Handler

See Payloads for how to add scopes to a payload using add_scopes_to_payload.

Sample Code

from sanic import Sanic
from sanic.response import json
from sanic_jwt import exceptions
from sanic_jwt import initialize
from sanic_jwt.decorators import protected
from sanic_jwt.decorators import scoped


class User(object):
    def __init__(self, id, username, password, scopes):
        self.user_id = id
        self.username = username
        self.password = password
        self.scopes = scopes

    def __str__(self):
        return "User(id='%s')" % self.id


users = [
    User(1, 'user1', 'abcxyz', ['user']),
    User(2, 'user2', 'abcxyz', ['user', 'admin']),
    User(3, 'user3', 'abcxyz', ['user:read']),
    User(4, 'user4', 'abcxyz', ['client1']),
]

username_table = {u.username: u for u in users}
userid_table = {u.user_id: u for u in users}


async def authenticate(request, *args, **kwargs):
    username = request.json.get('username', None)
    password = request.json.get('password', None)

    if not username or not password:
        raise exceptions.AuthenticationFailed("Missing username or password.")

    user = username_table.get(username, None)
    if user is None:
        raise exceptions.AuthenticationFailed("User not found.")

    if password != user.password:
        raise exceptions.AuthenticationFailed("Password is incorrect.")

    return user


async def my_scope_extender(user, *args, **kwargs):
    return user.scopes


app = Sanic()
Initialize(
    app,
    authenticate=authenticate,
    add_scopes_to_payload=my_scope_extender)


@app.route("/")
async def test(request):
    return json({"hello": "world"})


@app.route("/protected")
@protected()
async def protected_route(request):
    return json({"protected": True, "scoped": False})


@app.route("/protected/scoped/1")
@protected()
@scoped('user')
async def protected_route1(request):
    return json({"protected": True, "scoped": True})


@app.route("/protected/scoped/2")
@protected()
@scoped('user:read')
async def protected_route2(request):
    return json({"protected": True, "scoped": True})


@app.route("/protected/scoped/3")
@protected()
@scoped(['user', 'admin'])
async def protected_route3(request):
    return json({"protected": True, "scoped": True})


@app.route("/protected/scoped/4")
@protected()
@scoped(['user', 'admin'], False)
async def protected_route4(request):
    return json({"protected": True, "scoped": True})


@app.route("/protected/scoped/5")
@scoped('user')
async def protected_route5(request):
    return json({"protected": True, "scoped": True})


@app.route("/protected/scoped/6/<id>")
@scoped(lambda *args, **kwargs: 'user')
async def protected_route6(request, id):
    return json({"protected": True, "scoped": True})


def client_id_scope(request, *args, **kwargs):
    return 'client' + kwargs.get('id')


@app.route("/protected/scoped/7/<id>")
@scoped(client_id_scope)
async def protected_route7(request, id):
    return json({"protected": True, "scoped": True})


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8888)