Skip to content

Commit

Permalink
Merge pull request #74 from Asana/aw-asana-oauth-python
Browse files Browse the repository at this point in the history
Python OAuth example
  • Loading branch information
aw-asana authored Nov 22, 2024
2 parents e452d62 + 5e4a9f3 commit 5d08c8f
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 0 deletions.
3 changes: 3 additions & 0 deletions python/oauth-demo/.env-example
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
85 changes: 85 additions & 0 deletions python/oauth-demo/README.md
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"

![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.
108 changes: 108 additions & 0 deletions python/oauth-demo/app.py
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)
Binary file added python/oauth-demo/images/authedscreen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added python/oauth-demo/images/mainscreen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added python/oauth-demo/images/userauth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions python/oauth-demo/requirements.txt
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
10 changes: 10 additions & 0 deletions python/oauth-demo/static/helper.js
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";
}
42 changes: 42 additions & 0 deletions python/oauth-demo/static/index.html
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>
48 changes: 48 additions & 0 deletions python/oauth-demo/static/styles.css
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;
}

0 comments on commit 5d08c8f

Please sign in to comment.