-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #74 from Asana/aw-asana-oauth-python
Python OAuth example
- Loading branch information
Showing
10 changed files
with
300 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
CLIENT_ID=753482910 | ||
CLIENT_SECRET=6572195638271537892521 | ||
REDIRECT_URI=http://localhost:3000/oauth-callback |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
|
||
 | ||
|
||
5. Select "Allow" to grant the application access to your Asana account | ||
|
||
 | ||
|
||
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. | ||
|
||
 | ||
|
||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
Flask==3.0.3 | ||
python-dotenv==1.0.1 | ||
asana==5.0.10 | ||
requests==2.32.3 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8" /> | ||
|
||
<title>OAuth Demo</title> | ||
|
||
<link | ||
rel="stylesheet" | ||
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" | ||
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" | ||
crossorigin="anonymous" | ||
/> | ||
|
||
<link href="/static/styles.css" rel="stylesheet" /> | ||
</head> | ||
|
||
<body> | ||
<div class="main" id="unauthorized-body"> | ||
<div> | ||
<h1>OAuth Demo</h1> | ||
<p>Click to continue ⬇</p> | ||
</div> | ||
<div> | ||
<a class="button" href="authenticate">Authenticate with Asana</a> | ||
</div> | ||
</div> | ||
|
||
<!-- Hide this div unless authorized --> | ||
<div class="main" id="authorized-body"> | ||
<div> | ||
<h1>Success!</h1> | ||
<p>See your access token in the URL.</p> | ||
</div> | ||
<div> | ||
<a class="button" href="get-me">Fetch your user info!</a> | ||
</div> | ||
</div> | ||
|
||
<script type="text/javascript" src="static/helper.js"></script> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |