Skip to content

Commit 5242ac5

Browse files
authored
Merge pull request #12 from bjcoleman/oauth_in_app
Oauth in app
2 parents 95cab3c + f277bb9 commit 5242ac5

10 files changed

Lines changed: 955 additions & 9 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@
2929
This will run `setup.py` to create a `cost_sharing` package
3030

3131

32+
## Running the Application
33+
34+
From the project root directory (where `setup.py` is located):
35+
36+
```bash
37+
python -m cost_sharing.app
38+
```
39+
40+
The app will start on `http://localhost:8000`
41+
42+
3243
## Testing
3344

3445
Test can be run in the root of the project or from the `tests` folder. They cannot be run in `src` or its subfolders.

src/cost_sharing/app.py

Lines changed: 181 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,195 @@
1-
from flask import Flask
1+
import os
2+
import sys
3+
import functools
4+
from flask import Flask, request, jsonify, g, render_template
5+
from dotenv import load_dotenv
6+
from cost_sharing.oauth_handler import (
7+
OAuthHandler, OAuthCodeError, OAuthVerificationError,
8+
TokenExpiredError, TokenInvalidError
9+
)
10+
from cost_sharing.storage import InMemoryCostStorage
11+
from cost_sharing.cost_sharing import CostSharing
12+
from cost_sharing.exceptions import UserNotFoundError
213

3-
def create_app():
14+
15+
# Ignore "too-many-statements" because this function is going to be long!
16+
def create_app(oauth_handler, application): # pylint: disable=R0915
17+
"""
18+
Create and configure Flask application.
19+
20+
Args:
21+
oauth_handler: OAuthHandler instance for OAuth operations
22+
application: CostSharing application layer instance
23+
24+
Returns:
25+
Configured Flask application
26+
"""
427
app = Flask(__name__)
528

29+
def require_auth(f):
30+
"""
31+
Decorator to require JWT authentication for a route.
32+
33+
Extracts and validates JWT token from Authorization header.
34+
Stores user_id in Flask's g object for use in the route handler.
35+
Returns 401 Unauthorized if authentication fails.
36+
"""
37+
@functools.wraps(f)
38+
def decorated_function(*args, **kwargs):
39+
# Extract Authorization header
40+
auth_header = request.headers.get('Authorization')
41+
if not auth_header:
42+
return jsonify({
43+
"error": "Unauthorized",
44+
"message": "Authentication required"
45+
}), 401
46+
47+
# Check if header starts with "Bearer "
48+
if not auth_header.startswith('Bearer '):
49+
return jsonify({
50+
"error": "Unauthorized",
51+
"message": "Authentication required"
52+
}), 401
53+
54+
# Extract token
55+
token = auth_header[7:] # Remove "Bearer " prefix
56+
57+
try:
58+
# Validate token and get user_id
59+
user_id = oauth_handler.validate_jwt_token(token)
60+
# Store user_id in g for use in route handler
61+
g.user_id = user_id
62+
except (TokenExpiredError, TokenInvalidError):
63+
return jsonify({
64+
"error": "Unauthorized",
65+
"message": "Authentication required"
66+
}), 401
67+
68+
# Call the original route function
69+
return f(*args, **kwargs)
70+
71+
return decorated_function
72+
673
@app.route('/')
774
def index():
8-
return "Hello, World!"
75+
"""Serve the demo page."""
76+
return render_template('index.html')
77+
78+
@app.route('/auth/login', methods=['GET'])
79+
def auth_login():
80+
"""
81+
Get Google OAuth authorization URL.
82+
83+
Returns the URL that the frontend should redirect to for Google OAuth login.
84+
"""
85+
authorization_url, state = oauth_handler.get_authorization_url()
86+
return jsonify({
87+
"url": authorization_url,
88+
"state": state
89+
}), 200
90+
91+
@app.route('/auth/callback', methods=['GET'])
92+
def auth_callback():
93+
"""
94+
Handle OAuth callback from Google.
95+
96+
Exchanges authorization code for user info, creates/gets user, and returns JWT token.
97+
Called by JavaScript fetch after Google redirects to main page with code.
98+
"""
99+
# Extract code from query parameters
100+
code = request.args.get('code')
101+
if not code:
102+
return jsonify({
103+
"error": "Validation failed",
104+
"message": "code parameter is required"
105+
}), 400
106+
107+
try:
108+
# Exchange OAuth code for user information
109+
user_info = oauth_handler.exchange_code_for_user_info(code)
110+
email = user_info['email']
111+
name = user_info['name']
112+
113+
# Get or create user in the application
114+
user = application.get_or_create_user(email, name)
115+
116+
# Create JWT token for the user
117+
token = oauth_handler.create_jwt_token(user.id)
118+
119+
# Return token and user information as JSON
120+
return jsonify({
121+
"token": token,
122+
"user": {
123+
"id": user.id,
124+
"email": user.email,
125+
"name": user.name
126+
}
127+
}), 200
128+
129+
except OAuthCodeError:
130+
return jsonify({
131+
"error": "Validation failed",
132+
"message": "Invalid or expired authorization code"
133+
}), 400
134+
135+
except OAuthVerificationError:
136+
return jsonify({
137+
"error": "Unauthorized",
138+
"message": "OAuth verification failed"
139+
}), 401
140+
141+
@app.route('/auth/me', methods=['GET'])
142+
@require_auth
143+
def auth_me():
144+
"""
145+
Get current authenticated user information.
146+
147+
Requires valid JWT token in Authorization header.
148+
Returns user information (id, email, name).
149+
"""
150+
try:
151+
# Get user_id from g (set by require_auth decorator)
152+
user_id = g.user_id
153+
154+
# Get user from application layer
155+
user = application.get_user_by_id(user_id)
156+
157+
# Return user information
158+
return jsonify({
159+
"id": user.id,
160+
"email": user.email,
161+
"name": user.name
162+
}), 200
163+
164+
except UserNotFoundError:
165+
return jsonify({
166+
"error": "Resource not found",
167+
"message": "User not found"
168+
}), 404
9169

10170
return app
11171

12172

13173
# This is "main" for the gunicorn launch and cannot be tested directly
14174
def launch(): # pragma: no cover
15-
return create_app()
175+
"""Launch the Flask application with environment configuration."""
176+
# Load environment variables from .env file
177+
load_dotenv()
178+
179+
# Validate required environment variables
180+
for var in ['BASE_URL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'JWT_SECRET']:
181+
if not os.getenv(var):
182+
print(f"Error: Missing required environment variable: {var}", file=sys.stderr)
183+
sys.exit(1)
184+
185+
oauth_handler = OAuthHandler(
186+
base_url=os.getenv('BASE_URL'),
187+
google_client_id=os.getenv('GOOGLE_CLIENT_ID'),
188+
google_client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
189+
jwt_secret=os.getenv('JWT_SECRET')
190+
)
191+
192+
return create_app(oauth_handler, CostSharing(InMemoryCostStorage()))
16193

17194

18195
# This is "main" for the local launch and can be tested directly

src/cost_sharing/oauth_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(self, base_url, google_client_id, google_client_secret, jwt_secret)
3939
google_client_secret: Google OAuth client secret
4040
jwt_secret: Secret key for JWT token signing
4141
"""
42-
self.redirect_uri = f"{base_url}/auth/callback"
42+
self.redirect_uri = f"{base_url}/"
4343
self.google_client_id = google_client_id
4444
self.google_client_secret = google_client_secret
4545
self.jwt_secret = jwt_secret
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
body {
2+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
3+
max-width: 600px;
4+
margin: 50px auto;
5+
padding: 20px;
6+
background-color: #f5f5f5;
7+
}
8+
9+
.container {
10+
background: white;
11+
border-radius: 8px;
12+
padding: 30px;
13+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
14+
}
15+
16+
h1 {
17+
margin-top: 0;
18+
color: #333;
19+
}
20+
21+
.status {
22+
padding: 12px;
23+
border-radius: 4px;
24+
margin-bottom: 20px;
25+
font-weight: 500;
26+
}
27+
28+
.status.logged-in {
29+
background-color: #d4edda;
30+
color: #155724;
31+
border: 1px solid #c3e6cb;
32+
}
33+
34+
.status.logged-out {
35+
background-color: #f8d7da;
36+
color: #721c24;
37+
border: 1px solid #f5c6cb;
38+
}
39+
40+
.user-info {
41+
background-color: #f8f9fa;
42+
padding: 20px;
43+
border-radius: 4px;
44+
margin-bottom: 20px;
45+
}
46+
47+
.user-info h2 {
48+
margin-top: 0;
49+
font-size: 1.2em;
50+
color: #495057;
51+
}
52+
53+
.user-info p {
54+
margin: 8px 0;
55+
color: #6c757d;
56+
}
57+
58+
.user-info strong {
59+
color: #212529;
60+
}
61+
62+
.token-display {
63+
margin-top: 15px;
64+
padding-top: 15px;
65+
border-top: 1px solid #dee2e6;
66+
}
67+
68+
.token-display summary {
69+
cursor: pointer;
70+
color: #6c757d;
71+
font-size: 0.9em;
72+
user-select: none;
73+
}
74+
75+
.token-display summary:hover {
76+
color: #495057;
77+
}
78+
79+
.token-display code {
80+
display: block;
81+
margin-top: 8px;
82+
padding: 8px;
83+
background-color: #e9ecef;
84+
border-radius: 4px;
85+
font-size: 0.85em;
86+
word-break: break-all;
87+
color: #212529;
88+
}
89+
90+
button {
91+
background-color: #007bff;
92+
color: white;
93+
border: none;
94+
padding: 10px 20px;
95+
border-radius: 4px;
96+
cursor: pointer;
97+
font-size: 1em;
98+
margin-right: 10px;
99+
margin-bottom: 10px;
100+
}
101+
102+
button:hover {
103+
background-color: #0056b3;
104+
}
105+
106+
button:active {
107+
background-color: #004085;
108+
}
109+
110+
button.logout {
111+
background-color: #dc3545;
112+
}
113+
114+
button.logout:hover {
115+
background-color: #c82333;
116+
}
117+
118+
.error {
119+
background-color: #f8d7da;
120+
color: #721c24;
121+
padding: 12px;
122+
border-radius: 4px;
123+
margin-bottom: 20px;
124+
border: 1px solid #f5c6cb;
125+
}
126+
127+
.hidden {
128+
display: none;
129+
}
130+

0 commit comments

Comments
 (0)