diff --git a/python/oauth-demo/.env-example b/python/oauth-demo/.env-example new file mode 100644 index 0000000..a489776 --- /dev/null +++ b/python/oauth-demo/.env-example @@ -0,0 +1,3 @@ +CLIENT_ID=753482910 +CLIENT_SECRET=6572195638271537892521 +REDIRECT_URI=http://localhost:3000/oauth-callback \ No newline at end of file diff --git a/python/oauth-demo/README.md b/python/oauth-demo/README.md new file mode 100644 index 0000000..6283c2a --- /dev/null +++ b/python/oauth-demo/README.md @@ -0,0 +1,85 @@ +# OAuth Demo + +The OAuth Demo is an application that demonstrates authorization with a user's Asana account via a basic OAuth server (built with [Flask](https://flask.palletsprojects.com/en/stable/)). The application shows how you might send a user through the [user authorization endpoint](https://developers.asana.com/docs/oauth#user-authorization-endpoint), as well as how a `code` can be exchanged for a token via the [token exchange endpoint](https://developers.asana.com/docs/oauth#token-exchange-endpoint). + +_Note: This OAuth server should only be used for testing and learning purposes._ + +Documentation: Asana's [OAuth](https://developers.asana.com/docs/oauth) + +## Requirements +The application was built with Python 3.9.0. + +Visit Python's [official website](https://www.python.org/) to get the latest version for your local machine. + +### Dependencies: +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Requests](https://pypi.org/project/requests/) +- [python-dotenv](https://pypi.org/project/python-dotenv/) +- [Asana Python library](https://pypi.org/project/asana/) + +## Installation + +After cloning this project, navigate to the root directory and install dependencies: + +``` +pip install -r requirements.txt +``` + +## Usage + +1. [Create an application](https://developers.asana.com/docs/oauth#register-an-application). Take note of your **client ID** and **client secret**, and set the **redirect URI** to `http://localhost:3000/oauth-callback`. + +_Note: In order for a user to be able to authorize via the [user authorization endpoint](https://developers.asana.com/docs/oauth#user-authorization-endpoint), the application must be available in the user's workspace. See the [manage distribution](https://developers.asana.com/docs/manage-distribution) documentation for details._ + +2. Create a `.env` file (in the root directory of the project) with the required configurations: + +``` +CLIENT_ID=your_client_id_here +CLIENT_SECRET=your_client_secret_here +REDIRECT_URI=your_redirect_uri_here +``` + +You can view an example in the included `./.env-example` file. Note that you should _never_ commit or otherwise expose your `./.env` file publicly. + +3. Start the server: + +``` +python app.py +``` + +This will start the Flask application on `http://localhost:3000`. + +4. Visit [http://localhost:3000](http://localhost:3000) and click on "Authenticate with Asana" + +![user auth screen](./images/mainscreen.png) + +5. Select "Allow" to grant the application access to your Asana account + +![user auth screen](./images/userauth.png) + +You may also wish to view helpful outputs and notes in your terminal as well. + +6. After successful authentication, you will be notified and redirected by the application. + +![user auth screen](./images/authedscreen.png) + +Your access token (with an expiration of one hour) will also be loaded into the URL as a query parameter. With the access token, you can: + +* Select "Fetch your user info!" to have the application make a request to [GET /users/me](https://developers.asana.com/reference/getuser) on your behalf (and output the response as JSON in the browser) +* Use the access token to make an API request yourself (e.g., via the [API Explorer](https://developers.asana.com/docs/api-explorer), [Postman Collection](https://developers.asana.com/docs/postman-collection), etc.) + +## Deauthorizing the demo app + +To remove the app from your list of Authorized Apps: + +1. Click on your profile photo in the top right corner of the [Asana app](https://app.asana.com) +2. Select "My Settings" +3. Select the "App" tab +4. Select "Deauthorize" next to your application's name + +Once deauthorized, you must begin the OAuth process again to authenticate with Asana. + +## Additional notes + +* This OAuth demo server is built with Python and Flask. The server allows a user to authenticate via Asana, exchange the authorization code for an access token, and fetch user data from Asana. +* The Flask application stores session data (such as the OAuth state and tokens) using Flask's secure session system, protected by a secret key. This key is generated using `os.urandom(24)` and should never be exposed. \ No newline at end of file diff --git a/python/oauth-demo/app.py b/python/oauth-demo/app.py new file mode 100644 index 0000000..b8b53ee --- /dev/null +++ b/python/oauth-demo/app.py @@ -0,0 +1,108 @@ +import os +import uuid +import requests +from flask import Flask, request, redirect, jsonify, session, send_from_directory +from dotenv import load_dotenv +import asana +from asana.rest import ApiException + +# Load environment variables from the .env file +load_dotenv() + +app = Flask(__name__) +# Secret Key: os.urandom(24) generates a random secret key each time the server starts. +# For sessions to persist across server restarts and work in a distributed environment, +# set a consistent secret key (e.g., through environment variables) rather than using a random key. +app.secret_key = os.urandom(24) + +# Serve the main HTML page (index.html) from the 'static' directory. +@app.route('/') +def index(): + return send_from_directory('static', 'index.html') + +@app.route('/authenticate') +def authenticate(): + # Generate a state value and store it in the session + state = str(uuid.uuid4()) + session['state'] = state + + # Redirect user to Asana's OAuth authorization endpoint + # Documentation: https://developers.asana.com/docs/oauth#user-authorization-endpoint + asana_url = ( + f"https://app.asana.com/-/oauth_authorize?response_type=code&client_id={os.getenv('CLIENT_ID')}" + f"&redirect_uri={os.getenv('REDIRECT_URI')}&state={state}" + ) + return redirect(asana_url) + +@app.route('/oauth-callback') +def oauth_callback(): + # Get the state from the query parameters and compare it with the one stored in the session + state = request.args.get('state') + if state != session.get('state'): + return "State mismatch. Possible CSRF attack.", 422 + + # Get the code from the callback query + code = request.args.get('code') + + # Payload for the token exchange + # Documentation: https://developers.asana.com/docs/oauth#token-exchange-endpoint + token_url = 'https://app.asana.com/-/oauth_token' + data = { + 'grant_type': 'authorization_code', + 'client_id': os.getenv('CLIENT_ID'), + 'client_secret': os.getenv('CLIENT_SECRET'), + 'redirect_uri': os.getenv('REDIRECT_URI'), + 'code': code + } + + # Make a POST request to exchange the code for an access token + response = requests.post(token_url, data=data) + if response.status_code == 200: + # Extract tokens from the response + tokens = response.json() + access_token = tokens.get('access_token') + refresh_token = tokens.get('refresh_token') + + # Store tokens in the session (in production setting, this would be stored securely in a datbase) + session['access_token'] = access_token + session['refresh_token'] = refresh_token + + # Redirect back to the main page with, now with the access token in the URL query + return redirect(f"/?access_token={access_token}") + else: + return "Error exchanging code for token.", 500 + +# Make a simple GET request to /users/{user_gid} with the stored access token +# Documentation: https://developers.asana.com/reference/getuser +@app.route('/get-me') +def get_me(): + # See if the access token is available in the session + access_token = session.get('access_token') + if access_token: + # Set up the Asana client using the access token + configuration = asana.Configuration() + configuration.access_token = access_token + api_client = asana.ApiClient(configuration) + + # Create an instance of UsersApi + users_api_instance = asana.UsersApi(api_client) + # "me" is a special identifier that just represents the authenticated user + user_gid = "me" + + # Set any options to include specific fields + # Documentation: https://developers.asana.com/docs/inputoutput-options + opts = { + } + + try: + # Fetch the authenticated user's info + api_response = users_api_instance.get_user(user_gid, opts) + # Return the full JSON body of the 200 OK response + return jsonify(api_response) + except ApiException as e: + return f"Exception when calling UsersApi->get_user: {e}", 500 + else: + return redirect('/') + +if __name__ == '__main__': + app.run(debug=True, port=3000) diff --git a/python/oauth-demo/images/authedscreen.png b/python/oauth-demo/images/authedscreen.png new file mode 100644 index 0000000..ac5d0a5 Binary files /dev/null and b/python/oauth-demo/images/authedscreen.png differ diff --git a/python/oauth-demo/images/mainscreen.png b/python/oauth-demo/images/mainscreen.png new file mode 100644 index 0000000..35bcbcb Binary files /dev/null and b/python/oauth-demo/images/mainscreen.png differ diff --git a/python/oauth-demo/images/userauth.png b/python/oauth-demo/images/userauth.png new file mode 100644 index 0000000..2343eae Binary files /dev/null and b/python/oauth-demo/images/userauth.png differ diff --git a/python/oauth-demo/requirements.txt b/python/oauth-demo/requirements.txt new file mode 100644 index 0000000..c523463 --- /dev/null +++ b/python/oauth-demo/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +python-dotenv==1.0.1 +asana==5.0.10 +requests==2.32.3 diff --git a/python/oauth-demo/static/helper.js b/python/oauth-demo/static/helper.js new file mode 100644 index 0000000..ef5502e --- /dev/null +++ b/python/oauth-demo/static/helper.js @@ -0,0 +1,10 @@ +const QUERY_PARAMS = new URLSearchParams(window.location.search); +const ACCESS_TOKEN = QUERY_PARAMS.get("access_token"); + +// If an access token exists in the query params (i.e., suggesting successful auth) +if (ACCESS_TOKEN) { + // Hide the content from the unauthorized state (i.e., "Authenticate with Asana") + document.getElementById("unauthorized-body").style.display = "none"; + // Show the content from the authorized state (i.e., "Success!") + document.getElementById("authorized-body").style.display = "flex"; +} diff --git a/python/oauth-demo/static/index.html b/python/oauth-demo/static/index.html new file mode 100644 index 0000000..4ca79d7 --- /dev/null +++ b/python/oauth-demo/static/index.html @@ -0,0 +1,42 @@ + + + + + + OAuth Demo + + + + + + + +
+
+

OAuth Demo

+

Click to continue ⬇

+
+
+ Authenticate with Asana +
+
+ + +
+
+

Success!

+

See your access token in the URL.

+
+
+ Fetch your user info! +
+
+ + + + diff --git a/python/oauth-demo/static/styles.css b/python/oauth-demo/static/styles.css new file mode 100644 index 0000000..6eb7402 --- /dev/null +++ b/python/oauth-demo/static/styles.css @@ -0,0 +1,48 @@ +h1 { + font-size: 28px; + line-height: 30px; + margin: 30px auto 18px auto; +} + +a, +a:hover, +a:visited, +a:active { + color: #ffffff; + text-decoration: none; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #ef6a6a; +} + +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + text-align: center; + background-color: #ffffff; + width: 300px; + height: 260px; + border-radius: 10px; +} + +.button { + display: block; + font-size: 14px; + width: 100%; + color: #ffffff; + background-color: #4573d1; + padding: 10px 18px 10px 18px; + margin-bottom: 30px; + border-radius: 10px; +} + +#authorized-body { + display: none; +}