├── .gitignore ├── .env.example ├── requirements.txt ├── server.py ├── test_validator.py ├── validator.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | .env 3 | .venv 4 | venv 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ZITADEL_DOMAIN = "https://your-domain-abcdef.zitadel.cloud" 2 | CLIENT_ID = "197....@projectname" 3 | CLIENT_SECRET = "NVAp70IqiGmJldbS...." -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.2 2 | Authlib==1.2.0 3 | certifi==2022.12.7 4 | cffi==1.15.1 5 | charset-normalizer==3.0.1 6 | click==8.1.3 7 | cryptography==39.0.0 8 | distlib==0.3.6 9 | filelock==3.9.0 10 | Flask==2.2.2 11 | future==0.18.3 12 | idna==3.7 13 | importlib-metadata==6.0.0 14 | itsdangerous==2.1.2 15 | Jinja2==3.1.4 16 | jwt==1.3.1 17 | macholib==1.15.2 18 | MarkupSafe==2.1.1 19 | pathlib==1.0.1 20 | pipenv==2022.12.19 21 | platformdirs==2.6.2 22 | pycparser==2.21 23 | PyJWT==2.6.0 24 | python-dotenv==0.21.0 25 | requests==2.28.2 26 | six==1.15.0 27 | urllib3==1.26.18 28 | virtualenv==20.17.1 29 | virtualenv-clone==0.5.7 30 | Werkzeug==2.2.3 31 | zipp==3.11.0 32 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, Response 2 | from authlib.integrations.flask_oauth2 import ResourceProtector 3 | from validator import ZitadelIntrospectTokenValidator, ValidatorError 4 | 5 | require_auth = ResourceProtector() 6 | require_auth.register_token_validator(ZitadelIntrospectTokenValidator()) 7 | 8 | APP = Flask(__name__) 9 | 10 | @APP.errorhandler(ValidatorError) 11 | def handle_auth_error(ex: ValidatorError) -> Response: 12 | 13 | response = jsonify(ex.error) 14 | response.status_code = ex.status_code 15 | return response 16 | 17 | @APP.route("/api/public") 18 | def public(): 19 | """No access token required.""" 20 | response = ( 21 | "Public route - You don't need to be authenticated to see this." 22 | ) 23 | return jsonify(message=response) 24 | 25 | 26 | @APP.route("/api/private") 27 | @require_auth(None) 28 | def private(): 29 | """A valid access token is required.""" 30 | response = ( 31 | "Private route - You need to be authenticated to see this." 32 | ) 33 | return jsonify(message=response) 34 | 35 | 36 | @APP.route("/api/private-scoped") 37 | @require_auth(["read:messages"]) 38 | def private_scoped(): 39 | """A valid access token and scope are required.""" 40 | response = ( 41 | "Private, scoped route - You need to be authenticated and have the role read:messages to see this." 42 | ) 43 | return jsonify(message=response) 44 | 45 | if __name__ == "__main__": 46 | APP.run() -------------------------------------------------------------------------------- /test_validator.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from validator import ZitadelIntrospectTokenValidator as v 3 | from validator import ValidatorError 4 | 5 | class TestValidatorToken(unittest.TestCase): 6 | def test_invalid_token(self): 7 | token = {'active': False} 8 | scopes = None 9 | request = None 10 | with self.assertRaises(ValidatorError) as error: 11 | v.validate_token(None, token, scopes, request) 12 | self.assertEqual("{'code': 'invalid_token', 'description': 'Invalid token (active: false)'}", str(error.exception.error)) 13 | 14 | class TestValidatorMatchTokenScopes(unittest.TestCase): 15 | 16 | def test_no_scope_required(self): 17 | token = {'urn:zitadel:iam:org:project:roles': {'read:messages': {'170086305978381234': 'example.zitadel.cloud'}, 'write:messages': {'170086305978381234': 'example.zitadel.cloud'}}} 18 | scopes = None 19 | self.assertEqual(v.match_token_scopes(self, token, scopes), True) 20 | 21 | def test_one_valid_scope(self): 22 | token = {'urn:zitadel:iam:org:project:roles': {'read:messages': {'170086305978381234': 'example.zitadel.cloud'}, 'write:messages': {'170086305978381234': 'example.zitadel.cloud'}}} 23 | scopes = ['read:messages'] 24 | self.assertEqual(v.match_token_scopes(self, token, scopes), True) 25 | 26 | def test_wrong_scope(self): 27 | token = {'urn:zitadel:iam:org:project:roles': {'read:messages': {'170086305978381234': 'example.zitadel.cloud'}}} 28 | scopes = ['write:messages'] 29 | self.assertEqual(v.match_token_scopes(self, token, scopes), False) 30 | 31 | def test_and_scopes(self): 32 | token = {'urn:zitadel:iam:org:project:roles': {'read:messages': {'170086305978381234': 'example.zitadel.cloud'}, 'write:messages': {'170086305978381234': 'example.zitadel.cloud'}}} 33 | scopes = ['read:messages write:messages'] 34 | self.assertEqual(v.match_token_scopes(self, token, scopes), True) 35 | 36 | def test_and_scopes_missing_claim(self): 37 | token = {'urn:zitadel:iam:org:project:roles': {'read:messages': {'170086305978381234': 'example.zitadel.cloud'}}} 38 | scopes = ['read:messages write:messages'] 39 | self.assertEqual(v.match_token_scopes(self, token, scopes), False) 40 | 41 | def test_or_scopes(self): 42 | token = {'urn:zitadel:iam:org:project:roles': {'read:messages': {'170086305978381234': 'example.zitadel.cloud'}}} 43 | scopes = ['read:messages', 'write:messages'] 44 | self.assertEqual(v.match_token_scopes(self, token, scopes), True) 45 | 46 | if __name__ == '__main__': 47 | unittest.main() -------------------------------------------------------------------------------- /validator.py: -------------------------------------------------------------------------------- 1 | from os import environ as env 2 | import os 3 | import time 4 | from typing import Dict 5 | 6 | from authlib.oauth2.rfc7662 import IntrospectTokenValidator 7 | import requests 8 | from dotenv import load_dotenv, find_dotenv 9 | from requests.auth import HTTPBasicAuth 10 | 11 | load_dotenv() 12 | 13 | ZITADEL_DOMAIN = os.getenv("ZITADEL_DOMAIN") 14 | CLIENT_ID = os.getenv("CLIENT_ID") 15 | CLIENT_SECRET = os.getenv("CLIENT_SECRET") 16 | 17 | 18 | class ValidatorError(Exception): 19 | 20 | def __init__(self, error: Dict[str, str], status_code: int): 21 | super().__init__() 22 | self.error = error 23 | self.status_code = status_code 24 | 25 | # Use Introspection in Resource Server 26 | # https://docs.authlib.org/en/latest/specs/rfc7662.html#require-oauth-introspection 27 | 28 | class ZitadelIntrospectTokenValidator(IntrospectTokenValidator): 29 | def introspect_token(self, token_string): 30 | url = f'{ZITADEL_DOMAIN}/oauth/v2/introspect' 31 | data = {'token': token_string, 'token_type_hint': 'access_token', 'scope': 'openid'} 32 | auth = HTTPBasicAuth(CLIENT_ID, CLIENT_SECRET) 33 | resp = requests.post(url, data=data, auth=auth) 34 | resp.raise_for_status() 35 | return resp.json() 36 | 37 | def match_token_scopes(self, token, or_scopes): 38 | if or_scopes is None: 39 | return True 40 | roles = token["urn:zitadel:iam:org:project:roles"].keys() 41 | for and_scopes in or_scopes: 42 | scopes = and_scopes.split() 43 | """print(f"Check if all {scopes} are in {roles}")""" 44 | if all(key in roles for key in scopes): 45 | return True 46 | return False 47 | 48 | def validate_token(self, token, scopes, request): 49 | print (f"Token: {token}\n") 50 | now = int( time.time() ) 51 | if not token: 52 | raise ValidatorError({ 53 | "code": "invalid_token", 54 | "description": "Invalid Token." }, 401) 55 | """Revoked""" 56 | if not token["active"]: 57 | raise ValidatorError({ 58 | "code": "invalid_token", 59 | "description": "Invalid token (active: false)" }, 401) 60 | """Expired""" 61 | if token["exp"] < now: 62 | raise ValidatorError({ 63 | "code": "invalid_token_expired", 64 | "description": "Token has expired." }, 401) 65 | """Insufficient Scope""" 66 | if not self.match_token_scopes(token, scopes): 67 | raise ValidatorError({ 68 | "code": "insufficient_scope", 69 | "description": f"Token has insufficient scope. Route requires: {scopes}" }, 401) 70 | 71 | def __call__(self, *args, **kwargs): 72 | res = self.introspect_token(*args, **kwargs) 73 | return res -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example: Python3 Flask API Authentication and Authorization 2 | 3 | This example shows you how to create a Python3 API using Flask. 4 | 5 | ## Overview 6 | 7 | The API will have a public, private, and private-scoped route and check if a user is authenticated and authorized to access the routes. 8 | Our private routes expect an authorization header with an valid access_token in the request. 9 | The API will validate the access_token on the [introspect endpoint](https://zitadel.com/docs/apis/openidoauth/endpoints#introspection_endpoint) and receives the user's roles. 10 | 11 | The API application uses [Client Secret Basic](https://zitadel.com/docs/apis/openidoauth/authn-methods#client-secret-basic) to authenticate against ZITADEL and access the introspection endpoint. 12 | You can use any valid access_token from a user or service account to send requests to the example API. 13 | In this example we will use a service account with a [personal access token](https://zitadel.com/docs/guides/integrate/pat) which can be used directly to access the example API. 14 | 15 | ```mermaid 16 | graph LR; 17 | User-- request auth:access_token -->API; 18 | API<-- get claims auth:clientSecret -->introspect_endpoint 19 | API-- response -->User 20 | subgraph backend 21 | API-- validate -->API 22 | end 23 | subgraph zitadel 24 | introspect_endpoint 25 | end 26 | ``` 27 | 28 | ## Running the example 29 | 30 | In order to run the example you need to have `python3` and `pip` installed. 31 | 32 | ### ZITADEL configuration 33 | 34 | You need to setup a couple of things in ZITADEL. If you don't have an instance yet, please go ahead and create an instance following our [Get Started Guide](https://zitadel.com/docs/guides/start/quickstart). 35 | 36 | #### 1. Create an application 37 | 38 | Follow [our guide](https://zitadel.com/docs/guides/manage/console/applications) to create a new application with type "API" and authentication method "Basic". 39 | 40 | Save both the ClientID and ClientSecret. 41 | 42 | #### 2. Create a service user and PAT 43 | 44 | Follow [our guide](https://zitadel.com/docs/guides/manage/console/users#create-user) to create a service user. 45 | 46 | Create a personal access token, following [this guide](https://zitadel.com/docs/guides/integrate/pat#create-a-service-user-with-a-pat). No need to add the the user as manager. 47 | 48 | #### 3. Authorization 49 | 50 | Follow [this guide](https://zitadel.com/docs/guides/manage/console/roles) to create a role `read:messages` on your project. 51 | 52 | Authorize the service user, by adding the role `read:messages` to the user. 53 | 54 | ### Client configuration 55 | 56 | You just need to create a file named `.env` in the directory. 57 | There is in example configuration in the `.env.example` file. 58 | 59 | Set the values with the Instance Domain, Client ID, and Client Secret from the previous steps, and the client should work. 60 | 61 | ### Start the service 62 | 63 | 1. Install required dependencies with `pip install -r requirements.txt` 64 | 2. Start the server with `python3 server.py` 65 | 3. Open another terminal and follow the next step to test the API 66 | 67 | You can optionally create a virtual environment before installing the dependencies by running `python3 -m venv venv` 68 | 69 | ## Testing the API 70 | 71 | ### Public route 72 | 73 | Try calling the public route 74 | 75 | ``` 76 | curl --request GET \ 77 | --url http://127.0.0.1:5000/api/public 78 | ``` 79 | 80 | You should get a response with Status Code 200 and the following message. 81 | 82 | `{"message":"Public route - You don't need to be authenticated to see this."}` 83 | 84 | ### Private route 85 | 86 | Try calling the private route without authorization headers 87 | 88 | ``` 89 | curl --request GET \ 90 | --url http://127.0.0.1:5000/api/private 91 | ``` 92 | 93 | You should get a response with Status Code 401 and an error message. 94 | 95 | So let's add an authorization header to your request. Save the personal access token for your service user to a variable. 96 | 97 | `PAT=nr9vnUTkQkn4rxWk...` 98 | 99 | Then call the private route with the PAT in the authorization header. 100 | 101 | ``` 102 | curl --request GET \ 103 | --url http://127.0.0.1:5000/api/private \ 104 | --header "authorization: Bearer $PAT" 105 | ``` 106 | 107 | Now you should get a response with Status Code 200 and the following message. 108 | 109 | `{"message":"Private route - You need to be authenticated to see this."}` 110 | 111 | ### Private route, protected 112 | 113 | Try calling the private route that requires the user to have a certain role 114 | 115 | ``` 116 | curl --request GET \ 117 | --url http://127.0.0.1:5000/api/private-scoped \ 118 | --header "authorization: Bearer $PAT" 119 | ``` 120 | 121 | You should get a response with Status Code 200 and the following message. 122 | 123 | `{"message":"Private, scoped route - You need to be authenticated and have the role read:messages to see this."}` 124 | 125 | You can remove the role from the service user in ZITADEL and try again. You should then get Status Code 401, unauthorized. 126 | --------------------------------------------------------------------------------