Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@
This will run `setup.py` to create a `cost_sharing` package


## Running the Application

From the project root directory (where `setup.py` is located):

```bash
python -m cost_sharing.app
```

The app will start on `http://localhost:8000`


## Testing

Test can be run in the root of the project or from the `tests` folder. They cannot be run in `src` or its subfolders.
Expand Down
185 changes: 181 additions & 4 deletions src/cost_sharing/app.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,195 @@
from flask import Flask
import os
import sys
import functools
from flask import Flask, request, jsonify, g, render_template
from dotenv import load_dotenv
from cost_sharing.oauth_handler import (
OAuthHandler, OAuthCodeError, OAuthVerificationError,
TokenExpiredError, TokenInvalidError
)
from cost_sharing.storage import InMemoryCostStorage
from cost_sharing.cost_sharing import CostSharing
from cost_sharing.exceptions import UserNotFoundError

def create_app():

# Ignore "too-many-statements" because this function is going to be long!
def create_app(oauth_handler, application): # pylint: disable=R0915
"""
Create and configure Flask application.

Args:
oauth_handler: OAuthHandler instance for OAuth operations
application: CostSharing application layer instance

Returns:
Configured Flask application
"""
app = Flask(__name__)

def require_auth(f):
"""
Decorator to require JWT authentication for a route.

Extracts and validates JWT token from Authorization header.
Stores user_id in Flask's g object for use in the route handler.
Returns 401 Unauthorized if authentication fails.
"""
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# Extract Authorization header
auth_header = request.headers.get('Authorization')
if not auth_header:
return jsonify({
"error": "Unauthorized",
"message": "Authentication required"
}), 401

# Check if header starts with "Bearer "
if not auth_header.startswith('Bearer '):
return jsonify({
"error": "Unauthorized",
"message": "Authentication required"
}), 401

# Extract token
token = auth_header[7:] # Remove "Bearer " prefix

try:
# Validate token and get user_id
user_id = oauth_handler.validate_jwt_token(token)
# Store user_id in g for use in route handler
g.user_id = user_id
except (TokenExpiredError, TokenInvalidError):
return jsonify({
"error": "Unauthorized",
"message": "Authentication required"
}), 401

# Call the original route function
return f(*args, **kwargs)

return decorated_function

@app.route('/')
def index():
return "Hello, World!"
"""Serve the demo page."""
return render_template('index.html')

@app.route('/auth/login', methods=['GET'])
def auth_login():
"""
Get Google OAuth authorization URL.

Returns the URL that the frontend should redirect to for Google OAuth login.
"""
authorization_url, state = oauth_handler.get_authorization_url()
return jsonify({
"url": authorization_url,
"state": state
}), 200

@app.route('/auth/callback', methods=['GET'])
def auth_callback():
"""
Handle OAuth callback from Google.

Exchanges authorization code for user info, creates/gets user, and returns JWT token.
Called by JavaScript fetch after Google redirects to main page with code.
"""
# Extract code from query parameters
code = request.args.get('code')
if not code:
return jsonify({
"error": "Validation failed",
"message": "code parameter is required"
}), 400

try:
# Exchange OAuth code for user information
user_info = oauth_handler.exchange_code_for_user_info(code)
email = user_info['email']
name = user_info['name']

# Get or create user in the application
user = application.get_or_create_user(email, name)

# Create JWT token for the user
token = oauth_handler.create_jwt_token(user.id)

# Return token and user information as JSON
return jsonify({
"token": token,
"user": {
"id": user.id,
"email": user.email,
"name": user.name
}
}), 200

except OAuthCodeError:
return jsonify({
"error": "Validation failed",
"message": "Invalid or expired authorization code"
}), 400

except OAuthVerificationError:
return jsonify({
"error": "Unauthorized",
"message": "OAuth verification failed"
}), 401

@app.route('/auth/me', methods=['GET'])
@require_auth
def auth_me():
"""
Get current authenticated user information.

Requires valid JWT token in Authorization header.
Returns user information (id, email, name).
"""
try:
# Get user_id from g (set by require_auth decorator)
user_id = g.user_id

# Get user from application layer
user = application.get_user_by_id(user_id)

# Return user information
return jsonify({
"id": user.id,
"email": user.email,
"name": user.name
}), 200

except UserNotFoundError:
return jsonify({
"error": "Resource not found",
"message": "User not found"
}), 404

return app


# This is "main" for the gunicorn launch and cannot be tested directly
def launch(): # pragma: no cover
return create_app()
"""Launch the Flask application with environment configuration."""
# Load environment variables from .env file
load_dotenv()

# Validate required environment variables
for var in ['BASE_URL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'JWT_SECRET']:
if not os.getenv(var):
print(f"Error: Missing required environment variable: {var}", file=sys.stderr)
sys.exit(1)

oauth_handler = OAuthHandler(
base_url=os.getenv('BASE_URL'),
google_client_id=os.getenv('GOOGLE_CLIENT_ID'),
google_client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
jwt_secret=os.getenv('JWT_SECRET')
)

return create_app(oauth_handler, CostSharing(InMemoryCostStorage()))


# This is "main" for the local launch and can be tested directly
Expand Down
2 changes: 1 addition & 1 deletion src/cost_sharing/oauth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self, base_url, google_client_id, google_client_secret, jwt_secret)
google_client_secret: Google OAuth client secret
jwt_secret: Secret key for JWT token signing
"""
self.redirect_uri = f"{base_url}/auth/callback"
self.redirect_uri = f"{base_url}/"
self.google_client_id = google_client_id
self.google_client_secret = google_client_secret
self.jwt_secret = jwt_secret
Expand Down
130 changes: 130 additions & 0 deletions src/cost_sharing/static/css/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}

.container {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

h1 {
margin-top: 0;
color: #333;
}

.status {
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
font-weight: 500;
}

.status.logged-in {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}

.status.logged-out {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}

.user-info {
background-color: #f8f9fa;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
}

.user-info h2 {
margin-top: 0;
font-size: 1.2em;
color: #495057;
}

.user-info p {
margin: 8px 0;
color: #6c757d;
}

.user-info strong {
color: #212529;
}

.token-display {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}

.token-display summary {
cursor: pointer;
color: #6c757d;
font-size: 0.9em;
user-select: none;
}

.token-display summary:hover {
color: #495057;
}

.token-display code {
display: block;
margin-top: 8px;
padding: 8px;
background-color: #e9ecef;
border-radius: 4px;
font-size: 0.85em;
word-break: break-all;
color: #212529;
}

button {
background-color: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-right: 10px;
margin-bottom: 10px;
}

button:hover {
background-color: #0056b3;
}

button:active {
background-color: #004085;
}

button.logout {
background-color: #dc3545;
}

button.logout:hover {
background-color: #c82333;
}

.error {
background-color: #f8d7da;
color: #721c24;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
border: 1px solid #f5c6cb;
}

.hidden {
display: none;
}

Loading