├── README.md
├── backend
├── .env
├── client-credentials-token-generator.py
├── requirements.txt
├── server.py
└── validator.py
├── frontend
├── package.json
└── src
│ ├── App.js
│ ├── Login.js
│ ├── authConfig.js
│ ├── images
│ └── randy-tarampi-U2eUlPEKIgU-unsplash.jpg
│ └── style.css
└── screenshots
├── 1.png
├── 2.png
├── 3.png
├── 4.png
├── 5.png
├── 6.png
├── 7.png
└── 8.png
/README.md:
--------------------------------------------------------------------------------
1 | # example-quote-generator-app
2 | A simple web application using a React front-end and a Python back-end API, both secured using ZITADEL.
3 |
4 |
9 |
10 |
15 |
16 |
21 |
22 |
27 |
28 |
33 |
34 | ## Prerequisites to run the app:
35 |
36 | - Have python3 and pip3 installed in your machine (for the backend)
37 | - Have Node.js installed in your machine (for the frontend)
38 | - Create a free ZITADEL account here - https://zitadel.cloud/
39 | - Create an instance as explained [here](https://zitadel.com/docs/guides/start/quickstart#2-create-your-first-instance).
40 | - Create a new project in your instance by following the steps [here](https://zitadel.com/docs/guides/start/quickstart#2-create-your-first-instance).
41 |
42 | ## Run the backend
43 |
44 | ### Register the API in ZITADEL
45 |
46 | Follow these [instructions](https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/api-basic-authentication#1) to register an API application with Basic Authentication in ZITADEL.
47 |
48 | ### Run the API
49 |
50 | The API has a single route:
51 |
52 | - "/api/custom_quote" - A valid access token is required.
53 |
54 | 1. Create a folder named backend and add all the files in [/backend](https://github.com/zitadel/example-quote-generator-app/tree/main/backend) into it.
55 | 2. cd to the backend directory: `cd backend`
56 | 3. Create a new virtual environment for this project by running `python3 -m venv env`.
57 | 4. Install required dependencies by running `pip3 install -r requirements.txt` on your terminal.
58 | 5. Replace the values of `ZITADEL_DOMAIN`, `ZITADEL_INTROSPECTION_URL`, `API_CLIENT_ID` and `API_CLIENT_SECRET` in the .env file with your values you obtained earlier.
59 | 6. Run the API by running `python3 server.py` in the terminal.
60 |
61 |
62 | ### Obtain an access token via service user
63 |
64 | 1. Create a service user as instructed [here]([https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials](https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials#2-create-a-service-user-with-client-credentials-in-zitadel-)https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials#2-create-a-service-user-with-client-credentials-in-zitadel-). You can skip creating the role and authorization.
65 | 2. Obtain a token by running the client-credentials-token-generator.py as instructed [here](https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials#3-generate-a-token-). You can perform the instructions in this directory in a different terminal.
66 |
67 | ### Test the API with the access token
68 |
69 | 1. Invoke the API using the following cURL command:
70 | `curl -X GET -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/custom_quote`
71 |
72 | 2. You should get a response with Status Code 200 in the following format:
73 | `{"quote":"If you're going through hell, keep going. - Winston Churchill"}`
74 |
75 | Now the API is ready to be consumed by our front-end application.
76 |
77 | ## Run the frontend application
78 |
79 | 1. Follow the ZITADEL [Quickstart Guide](https://zitadel.com/docs/guides/start/quickstart) up to [Create your React application with ZITADEL OIDC PKCE authentication](https://zitadel.com/docs/guides/start/quickstart#create-your-react-application-with-zitadel-oidc-pkce-authentication). We will go through the steps to create the React app for this tutorial below. But before that, here are some changes to note:
80 | - Since you already created an instance and project for the backend, you can use the same project to create the Single Page Application in ZITADEL (or you can follow the guide and create a new project altogether as well). The front-end application and API application were both created in the same ZITADEL project for this app as shown below.
81 |
82 |
87 |
88 | - Also, you do not need to create roles and authorizations as stated in the Quickstart.
89 | - Make sure to modify the Zitadel redirect_uri configuration to be just `http://localhost:3000/` (the Quickstart guide uses `http://localhost:3000/callback`).
90 |
91 |
96 |
97 | - You must also go to Token Settings in the front-end app and select User Info inside ID Token as shown below:
98 |
99 |
104 |
105 | 3. Navigate to the folder where you want to create the React app.
106 | 4. Run the following command to create a new React app named "zitadel-app": `npx create-react-app zitadel-app`
107 | 5. Navigate to the "zitadel-app" folder: `cd zitadel-app`
108 | 6. Install the dependencies by running the following: `npm install --save jwt-decode oidc-client-ts react react-dom react-router-dom`\
109 | 7. Replace the content in your `App.js` file with [src/App.js](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/App.js).
110 | 8. Create a file named `Login.js` and paste the code in [src/Login/js](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/Login.js).
111 | 9. Create a file named `authConfig.js` and add to it the content from [src/authConfig.js](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/authConfig.js). Edit the file by adding your values your obtained from ZITADEL. Make sure the PROJECT_ID in the scope is replaced with the project ID of the project where your API resides.
112 | 10. Add a new file called `style.css` to the src folder to apply CSS styling to the pages. Copy paste the code from [src/style.css](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/style.css).
113 | 11. Create a folder called `images` and add the image in the [/images](https://github.com/zitadel/example-quote-generator-app/tree/main/frontend/src/images) folder.
114 | 12. Add the line `"proxy": "http://localhost:5000"` (the URL of the back-end API) to your `package.json` file so that it looks something like this:
115 | ```
116 | {
117 | "name": "my-app",
118 | "version": "0.1.0",
119 | "private": true,
120 | "proxy": "http://localhost:5000",
121 | "dependencies": {
122 | "react": "^17.0.1",
123 | "react-dom": "^17.0.1",
124 | "react-scripts": "4.0.1",
125 | //...
126 | },
127 | //...
128 | }
129 | ```
130 |
131 |
132 | The "proxy" field in package.json tells the development server to proxy any unknown requests to the specified address. This helps us bypass CORS issues because the requests will be served from the same domain as far as the client (browser) is concerned.
133 | So, in the React code, the fetch request looks like:
134 |
135 | ```
136 | fetch('/api/custom_quote')
137 | .then(response => response.json())
138 | .then(data => this.setState({ quote: data.quote }));
139 | ```
140 |
141 | Please note that this only works when you're running your React app using the development server with `npm start` or `yarn start`.
142 |
143 | 13. Run `npm start` inside the zitadel-app folder. If everything is set up properly, you will have your application running at `http://localhost:3000/`.
144 |
145 | ## Test the web application end-to-end
146 |
147 | 1. Test the application by clicking on the log in button. The front-end app uses Authorization Code with PKCE flow for user authentication using ZITADEL.
148 | 2. The user will be redirected to ZITADEL where he has to log in as a ZITADEL user.
149 | 3. If the login was successful, ZITADEL will send an access token along with an id token to the front-end application.
150 | 4. The front-end application will greet the user and show the option to generate a quote via a button. The user's name was extracted from the id_token returned by ZITADEL.
151 | 5. When the user presses the 'Generate quote" button, the back-end API will be called with the user's access token.
152 | 6. The back-end API is protected and will introspect the access token by calling ZITADEL's introspection endpoint. If the access token is active/valid, the API will send the response to the front-end application.
153 | 7. The user will be abe to view the quote on the browser.
154 |
155 |
--------------------------------------------------------------------------------
/backend/.env:
--------------------------------------------------------------------------------
1 | ZITADEL_DOMAIN="https://abc-123-fed-123.zitadel.cloud" # Replace this with your issuer URL
2 | ZITADEL_INTROSPECTION_URL="https://abc-123-fed-123.zitadel.cloud/oauth/v2/introspect" # Replace this with your introspection URL
3 | API_BASE_URL="http://localhost:5000"
4 | API_CLIENT_ID="12345@test_project" # Replace this with the cliend ID of the API APPLICATION (Basic Auth option)
5 | API_CLIENT_SECRET="SECRET23456" # Replace this with the cliend secret of the API APPLICATION (Basic Auth option)
6 | ZITADEL_TOKEN_URL="https://abc-123-fed-123.zitadel.cloud/oauth/v2/token" # Replace this with your token URL
7 | CLIENT_ID="tester" # Replace this with your service user's id (client credentials option)
8 | CLIENT_SECRET="SECRET" # Replace this with your service user's secret (client credentials option)
9 | PROJECT_ID="12453453232323" # Add you Project ID/Resource ID.
10 |
--------------------------------------------------------------------------------
/backend/client-credentials-token-generator.py:
--------------------------------------------------------------------------------
1 | import os
2 | import requests
3 | import base64
4 | from dotenv import load_dotenv
5 |
6 | load_dotenv()
7 |
8 | ZITADEL_DOMAIN = os.getenv("ZITADEL_DOMAIN")
9 | CLIENT_ID = os.getenv("CLIENT_ID")
10 | CLIENT_SECRET = os.getenv("CLIENT_SECRET")
11 | ZITADEL_TOKEN_URL = os.getenv("ZITADEL_TOKEN_URL")
12 | PROJECT_ID = os.getenv("PROJECT_ID")
13 |
14 | # Encode the client ID and client secret in Base64
15 | client_credentials = f"{CLIENT_ID}:{CLIENT_SECRET}".encode("utf-8")
16 | base64_client_credentials = base64.b64encode(client_credentials).decode("utf-8")
17 |
18 | # Request an OAuth token from ZITADEL
19 | headers = {
20 | "Content-Type": "application/x-www-form-urlencoded",
21 | "Authorization": f"Basic {base64_client_credentials}"
22 | }
23 |
24 | data = {
25 | "grant_type": "client_credentials",
26 | "scope": f"openid profile email urn:zitadel:iam:org:project:id:{PROJECT_ID}:aud urn:zitadel:iam:org:projects:roles urn:zitadel:iam:org:project:roles"
27 |
28 |
29 | }
30 |
31 | response = requests.post(ZITADEL_TOKEN_URL, headers=headers, data=data)
32 |
33 | if response.status_code == 200:
34 | access_token = response.json()["access_token"]
35 | print(f"Response: {response.json()}")
36 | print(f"Access token: {access_token}")
37 | else:
38 | print(f"Error: {response.status_code} - {response.text}")
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | Authlib==1.2.0
2 | Flask==2.2.2
3 | PyJWT==2.7.0
4 | requests==2.28.2
5 | python-dotenv==0.21.0
6 | Flask-Cors==3.0.10
7 |
--------------------------------------------------------------------------------
/backend/server.py:
--------------------------------------------------------------------------------
1 | # server.py
2 | from flask import Flask, jsonify, Response, request
3 | from authlib.integrations.flask_oauth2 import ResourceProtector
4 | from validator import ZitadelIntrospectTokenValidator, ValidatorError
5 | from flask.json import JSONEncoder
6 | import requests
7 | import random
8 | import os
9 |
10 |
11 | class CustomJSONEncoder(JSONEncoder):
12 | def __init__(self, *args, **kwargs):
13 | kwargs['ensure_ascii'] = False # Prevents unicode escapes
14 | super(CustomJSONEncoder, self).__init__(*args, **kwargs)
15 |
16 |
17 | app = Flask(__name__)
18 | app.json_encoder = CustomJSONEncoder
19 |
20 |
21 | require_auth = ResourceProtector()
22 | require_auth.register_token_validator(ZitadelIntrospectTokenValidator())
23 |
24 |
25 | @app.errorhandler(ValidatorError)
26 | def handle_auth_error(ex: ValidatorError) -> Response:
27 | response = jsonify(ex.error)
28 | response.status_code = ex.status_code
29 | return response
30 |
31 |
32 | @app.route("/api/custom_quote")
33 | @require_auth(None)
34 | def custom_quote():
35 | quotes = [
36 | "'Believe you can and you're halfway there'. - Theodore Roosevelt",
37 | "'Don't watch the clock; do what it does. Keep going'. - Sam Levenson",
38 | "'Whether you think you can or you think you can’t, you’re right'. - Henry Ford",
39 | "'The only way to do great work is to love what you do'. - Steve Jobs",
40 | "'You are never too old to set another goal or to dream a new dream'. - C.S. Lewis",
41 | "'The only difference between a rut and a grave is their dimensions'. - Ellen Glasgow",
42 | "'If you're going through hell, keep going'. - Winston Churchill",
43 | "'People often say that motivation doesn't last. Well, neither does bathing – that’s why we recommend it daily'. - Zig Ziglar",
44 | "'If you think you are too small to make a difference, try sleeping with a mosquito'. - Dalai Lama",
45 | "'I find that the harder I work, the more luck I seem to have'. - Thomas Jefferson",
46 | "'I have not failed. I’ve just found 10,000 ways that won’t work'. - Thomas A. Edison"
47 | ] # List of inspirational but funny quotes
48 |
49 |
50 | random_quote = random.choice(quotes)
51 |
52 | return jsonify(quote=random_quote)
53 |
54 |
55 |
56 |
57 | if __name__ == "__main__":
58 | app.run()
59 |
--------------------------------------------------------------------------------
/backend/validator.py:
--------------------------------------------------------------------------------
1 | #validator.py
2 | from os import environ as env
3 | import os
4 | import time
5 | from typing import Dict
6 |
7 | from authlib.oauth2.rfc7662 import IntrospectTokenValidator
8 | import requests
9 | from dotenv import load_dotenv, find_dotenv
10 | from requests.auth import HTTPBasicAuth
11 |
12 | load_dotenv()
13 |
14 | ZITADEL_DOMAIN = os.getenv("ZITADEL_DOMAIN")
15 | ZITADEL_INTROSPECTION_URL = os.getenv("ZITADEL_INTROSPECTION_URL")
16 | API_CLIENT_ID = os.getenv("API_CLIENT_ID")
17 | API_CLIENT_SECRET = os.getenv("API_CLIENT_SECRET")
18 |
19 |
20 | class ValidatorError(Exception):
21 |
22 | def __init__(self, error: Dict[str, str], status_code: int):
23 | super().__init__()
24 | self.error = error
25 | self.status_code = status_code
26 |
27 | class ZitadelIntrospectTokenValidator(IntrospectTokenValidator):
28 | def introspect_token(self, token_string):
29 | url = ZITADEL_INTROSPECTION_URL
30 | data = {'token': token_string, 'token_type_hint': 'access_token', 'scope': 'openid'}
31 | auth = HTTPBasicAuth(API_CLIENT_ID, API_CLIENT_SECRET)
32 | resp = requests.post(url, data=data, auth=auth)
33 | resp.raise_for_status()
34 | return resp.json()
35 |
36 | def match_token_scopes(self, token, or_scopes):
37 | if or_scopes is None:
38 | return True
39 | scopes = token.get("scope", "").split()
40 | for and_scopes in or_scopes:
41 | if all(key in scopes for key in and_scopes.split()):
42 | return True
43 | return False
44 |
45 | def validate_token(self, token, scopes, request):
46 | print(f"Token: {token}\n")
47 | now = int(time.time())
48 | if not token:
49 | raise ValidatorError({
50 | "code": "invalid_token_revoked",
51 | "description": "Token was revoked."
52 | }, 401)
53 | if token["exp"] < now:
54 | raise ValidatorError({
55 | "code": "invalid_token_expired",
56 | "description": "Token has expired."
57 | }, 401)
58 | if not token.get("active"):
59 | raise ValidatorError({
60 | "code": "invalid_token_inactive",
61 | "description": "Token is inactive."
62 | }, 401)
63 | if not self.match_token_scopes(token, scopes):
64 | raise ValidatorError({
65 | "code": "insufficient_scope",
66 | "description": f"Token has insufficient scope. Route requires: {scopes}"
67 | }, 401)
68 |
69 |
70 | def __call__(self, *args, **kwargs):
71 | res = self.introspect_token(*args, **kwargs)
72 | return res
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zitadel-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "proxy": "http://localhost:5000",
6 | "dependencies": {
7 | "@testing-library/jest-dom": "^5.16.5",
8 | "@testing-library/react": "^13.4.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "jwt-decode": "^3.1.2",
11 | "oidc-client-ts": "^2.2.4",
12 | "react": "^18.2.0",
13 | "react-dom": "^18.2.0",
14 | "react-router-dom": "^6.13.0",
15 | "react-scripts": "5.0.1",
16 | "web-vitals": "^2.1.4"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test",
22 | "eject": "react-scripts eject"
23 | },
24 | "eslintConfig": {
25 | "extends": [
26 | "react-app",
27 | "react-app/jest"
28 | ]
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/App.js:
--------------------------------------------------------------------------------
1 | //App.js
2 | import React, { useState, useEffect } from "react";
3 | import { BrowserRouter, Routes, Route } from "react-router-dom";
4 | import Login from "./Login";
5 | import authConfig from "./authConfig";
6 | import { UserManager, WebStorageStateStore } from "oidc-client-ts";
7 |
8 | function App() {
9 | const [userManager, setUserManager] = useState(null);
10 | const [authenticated, setAuthenticated] = useState(null);
11 | const [userInfo, setUserInfo] = useState(null);
12 |
13 | useEffect(() => {
14 | const manager = new UserManager({
15 | userStore: new WebStorageStateStore({ store: window.localStorage }),
16 | ...authConfig,
17 | });
18 |
19 | setUserManager(manager);
20 | }, []);
21 |
22 | useEffect(() => {
23 | if (userManager) {
24 | userManager.getUser().then((user) => {
25 | if (user) {
26 | setAuthenticated(true);
27 | setUserInfo(user); // Store the entire user object
28 | } else {
29 | setAuthenticated(false);
30 | }
31 | });
32 |
33 | if (window.location.href.includes('id_token') || window.location.href.includes('code')) {
34 | userManager.signinRedirectCallback()
35 | .then((user) => {
36 | if (user) {
37 | console.log('Redirect callback user', user);
38 | setAuthenticated(true);
39 | setUserInfo(user); // Store the entire user object
40 | }
41 | })
42 | .catch((error) => {
43 | console.error('Sign-in error', error);
44 | });
45 | }
46 | }
47 | }, [userManager]);
48 |
49 |
50 |
51 | function authorize() {
52 | userManager && userManager.signinRedirect({ state: "a2123a67ff11413fa19217a9ea0fbad5" });
53 | }
54 |
55 | function clearAuth() {
56 | userManager && userManager.signoutRedirect();
57 | }
58 |
59 | if (!userManager) {
60 | return
Loading quote...
} 57 | {error &&{error}
} 58 | {quote &&Hey there, {userInfo.profile.name}! Here's a fun little quote just for you: {quote}
} 60 | {quote &&This quote was also emailed to you at {userInfo.profile.email}.
} 61 |