Skip to content

Commit 0746be9

Browse files
committed
Authorization: Web API protected by access token
1 parent 7b900d3 commit 0746be9

File tree

13 files changed

+535
-38
lines changed

13 files changed

+535
-38
lines changed

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ you can read its HTML source code, and find the how-to instructions there.
103103
</tr>
104104

105105
<tr>
106-
<th>Web App Calls a web API</th>
106+
<th>Your Web App Calls a Web API on behalf of the user</th>
107107
<td colspan=4>
108108

109109
This library supports:
@@ -118,7 +118,22 @@ They are demonstrated by the same samples above.
118118
</tr>
119119

120120
<tr>
121-
<th>Web API Calls another web API (On-behalf-of)</th>
121+
<th>Your Web API protected by an access token</th>
122+
<td colspan=4>
123+
124+
By using this library, it will automatically emit
125+
HTTP 401 or 403 error when the access token is absent or invalid.
126+
127+
* Sample written in ![Django](https://raw.githubusercontent.com/rayluo/identity/dev/docs/django.webp)(Coming soon)
128+
* [Sample written in ![Flask](https://raw.githubusercontent.com/rayluo/identity/dev/docs/flask.webp)](https://github.com/rayluo/python-webapi-flask.git)
129+
* Need support for more web frameworks?
130+
[Upvote existing feature request or create a new one](https://github.com/rayluo/identity/issues)
131+
132+
</td>
133+
</tr>
134+
135+
<tr>
136+
<th>Your Web API Calls another web API on behalf of the user (OBO)</th>
122137
<td colspan=4>
123138

124139
In roadmap.

docs/app-vs-api.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.. note::
2+
3+
Web Application (a.k.a. website) and Web API are different,
4+
and are supported by different Identity components.
5+
Make sure you are using the right component for your scenario.
6+
7+
+-------------------------+---------------------------------------------------+-------------------------------------------------------+
8+
| Aspects | Web Application (a.k.a. website) | Web API |
9+
+=========================+===================================================+=======================================================+
10+
| **Definition** | A complete solution that users interact with | A back-end system that provides data (typically in |
11+
| | directly through their browsers. | JSON format) to front-end or other system. |
12+
+-------------------------+---------------------------------------------------+-------------------------------------------------------+
13+
| **Functionality** | - Users interact with views (HTML user interfaces)| - Does not return views (in HTML); only provides data.|
14+
| | and data. | - Other systems (clients) hit its endpoints. |
15+
| | - Users sign in and establish their sessions. | - Clients presents a token to access your API. |
16+
| | | - Each request has no session. They are stateless. |
17+
+-------------------------+---------------------------------------------------+-------------------------------------------------------+
18+

docs/django-webapi.rst

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
Identity for a Django Web API
2+
=============================
3+
4+
.. include:: app-vs-api.rst
5+
6+
Prerequisite
7+
------------
8+
9+
Create a hello world web project in Django.
10+
11+
You can use
12+
`Django's own tutorial, part 1 <https://docs.djangoproject.com/en/5.0/intro/tutorial01/>`_
13+
as a reference. What we need are basically these steps:
14+
15+
#. ``django-admin startproject mysite``
16+
#. ``python manage.py migrate`` (Optinoal if your project does not use a database)
17+
#. ``python manage.py runserver localhost:5000``
18+
19+
#. Now, add a new `mysite/views.py` file with an `index` view to your project.
20+
For now, it can simply return a "hello world" page to any visitor::
21+
22+
from django.http import JsonResponse
23+
def index(request):
24+
return JsonResponse({"message": "Hello, world!"})
25+
26+
Configuration
27+
-------------
28+
29+
#. Install dependency by ``pip install identity[django]``
30+
31+
#. Create an instance of the :py:class:`identity.django.Auth` object,
32+
and assign it to a global variable inside your ``settings.py``::
33+
34+
import os
35+
from identity.django import Auth
36+
AUTH = Auth(
37+
client_id=os.getenv('CLIENT_ID'),
38+
...=..., # See below on how to feed in the authority url parameter
39+
)
40+
41+
.. include:: auth.rst
42+
43+
44+
Django Web API protected by an access token
45+
-------------------------------------------
46+
47+
#. In your web project's ``views.py``, decorate some views with the
48+
:py:func:`identity.django.ApiAuth.authorization_required` decorator::
49+
50+
from django.conf import settings
51+
52+
@settings.AUTH.authorization_required(expected_scopes={
53+
"your_scope_1": "api://your_client_id/your_scope_1",
54+
"your_scope_2": "api://your_client_id/your_scope_2",
55+
})
56+
def index(request, *, context):
57+
claims = context['claims']
58+
# The user is uniquely identified by claims['sub'] or claims["oid"],
59+
# claims['tid'] and/or claims['iss'].
60+
return JsonResponse(
61+
{"message": f"Data for {claims['sub']}@{claims['tid']}"}
62+
)
63+
64+
65+
All of the content above are demonstrated in
66+
`this django web app sample <https://github.com/Azure-Samples/ms-identity-python-webapi-django>`_.
67+
68+
69+
API for Django web projects
70+
---------------------------
71+
72+
.. autoclass:: identity.django.ApiAuth
73+
:members:
74+
:inherited-members:
75+
76+
.. automethod:: __init__
77+

docs/django.rst

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
Identity for Django
2-
===================
1+
Identity for a Django Web App
2+
=============================
3+
4+
.. include:: app-vs-api.rst
35

46
Prerequisite
57
------------
@@ -15,15 +17,15 @@ as a reference. What we need are basically these steps:
1517
#. ``python manage.py runserver localhost:5000``
1618
You must use a port matching your redirect_uri that you registered.
1719

18-
#. Now, add an `index` view to your project.
20+
#. Now, add a new `mysite/views.py` file with an `index` view to your project.
1921
For now, it can simply return a "hello world" page to any visitor::
2022

2123
from django.http import HttpResponse
2224
def index(request):
2325
return HttpResponse("Hello, world. Everyone can read this line.")
2426

2527
Configuration
26-
---------------------------------
28+
-------------
2729

2830
#. Install dependency by ``pip install identity[django]``
2931

docs/flask-webapi.rst

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
Identity for a Flask Web API
2+
============================
3+
4+
.. include:: app-vs-api.rst
5+
6+
Prerequisite
7+
------------
8+
9+
Create `a hello world web project in Flask <https://flask.palletsprojects.com/en/3.0.x/quickstart/#a-minimal-application>`_.
10+
Here we assume the project's main file is named ``app.py``.
11+
12+
13+
Configuration
14+
-------------
15+
16+
#. Install dependency by ``pip install identity[flask]``
17+
18+
#. Create an instance of the :py:class:`identity.Flask.ApiAuth` object,
19+
and assign it to a global variable inside your ``app.py``::
20+
21+
import os
22+
from flask import Flask
23+
from identity.flask import ApiAuth
24+
25+
app = Flask(__name__)
26+
auth = ApiAuth(
27+
client_id=os.getenv('CLIENT_ID'),
28+
...=..., # See below on how to feed in the authority url parameter
29+
)
30+
31+
.. include:: auth.rst
32+
33+
34+
Flask Web API protected by an access token
35+
------------------------------------------
36+
37+
#. In your web project's ``app.py``, decorate some views with the
38+
:py:func:`identity.flask.ApiAuth.authorization_required` decorator.
39+
It will automatically put validated token claims into the ``context`` dictionary,
40+
under the key ``claims``.
41+
or emit an HTTP 401 or 403 response if the token is missing or invalid.
42+
43+
::
44+
45+
@app.route("/")
46+
@auth.authorization_required(expected_scopes={
47+
"your_scope_1": "api://your_client_id/your_scope_1",
48+
"your_scope_2": "api://your_client_id/your_scope_2",
49+
})
50+
def index(*, context):
51+
claims = context['claims']
52+
# The user is uniquely identified by claims['sub'] or claims["oid"],
53+
# claims['tid'] and/or claims['iss'].
54+
return {"message": f"Data for {claims['sub']}@{claims['tid']}"}
55+
56+
All of the content above are demonstrated in
57+
`this Flask web API sample <https://github.com/Azure-Samples/ms-identity-python-webapi-flask>`_.
58+
59+
API for Flask web API projects
60+
------------------------------
61+
62+
.. autoclass:: identity.flask.ApiAuth
63+
:members:
64+
:inherited-members:
65+
66+
.. automethod:: __init__
67+

docs/flask.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
Identity for Flask
2-
==================
1+
Identity for a Flask Web App
2+
============================
3+
4+
.. include:: app-vs-api.rst
35

46
Prerequisite
57
------------
@@ -9,7 +11,7 @@ Here we assume the project's main file is named ``app.py``.
911

1012

1113
Configuration
12-
--------------------------------
14+
-------------
1315

1416
#. Install dependency by ``pip install identity[flask]``
1517

docs/index.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ This Identity library is a Python authentication/authorization library that:
4747
:hidden:
4848

4949
django
50+
django-webapi
5051
flask
52+
flask-webapi
5153
quart
5254
abc
5355
generic
@@ -60,3 +62,9 @@ This Identity library is a Python authentication/authorization library that:
6062
Other modules in the source code are all considered as internal helpers,
6163
which could change at anytime in the future, without prior notice.
6264

65+
This library is designed to be used in either a web app or a web API.
66+
Understand the difference between the two scenarios,
67+
before you choose the right component to build your project.
68+
69+
.. include:: app-vs-api.rst
70+

identity/django.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
from django.shortcuts import redirect, render
88
from django.urls import include, path, reverse
9+
from django.http import HttpResponse
910

10-
from .web import WebFrameworkAuth
11+
from .web import WebFrameworkAuth, HttpError, ApiAuth as _ApiAuth
1112

1213

1314
logger = logging.getLogger(__name__)
@@ -210,3 +211,18 @@ def wrapper(request, *args, **kwargs):
210211
)
211212
return wrapper
212213

214+
215+
class ApiAuth(_ApiAuth):
216+
def authorization_required(self, *, expected_scopes, **kwargs):
217+
def decorator(function):
218+
@wraps(function)
219+
def wrapper(request, *args, **kwargs):
220+
try:
221+
context = self._validate(request, expected_scopes=expected_scopes)
222+
except HttpError as e:
223+
return HttpResponse(
224+
e.description, status=e.status_code, headers=e.headers)
225+
return function(request, *args, context=context, **kwargs)
226+
return wrapper
227+
return decorator
228+

identity/flask.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import functools
12
from typing import List, Optional # Needed in Python 3.7 & 3.8
23
from flask import (
34
Blueprint, Flask,
5+
abort, make_response, # Used in ApiAuth
46
redirect, render_template, request, session, url_for,
57
)
68
from flask_session import Session
79
from .pallet import PalletAuth
810

11+
from .web import WebFrameworkAuth, ApiAuth as _ApiAuth
12+
913

1014
class Auth(PalletAuth):
1115
"""A long-live identity auth helper for a Flask web project."""
@@ -171,3 +175,18 @@ def logout(self):
171175
self._post_logout_view.__name__, _external=True,
172176
) if self._post_logout_view else None)
173177

178+
class ApiAuth(_ApiAuth):
179+
def raise_http_error(self, status_code, *, headers=None, description=None):
180+
response = make_response(description, status_code)
181+
response.headers.extend(headers or {})
182+
abort(response)
183+
184+
def authorization_required(self, *, expected_scopes, **kwargs):
185+
def decorator(function):
186+
@functools.wraps(function)
187+
def wrapper(*args, **kwargs):
188+
context = self._validate(request, expected_scopes=expected_scopes)
189+
return function(*args, context=context, **kwargs)
190+
return wrapper
191+
return decorator
192+

0 commit comments

Comments
 (0)