|
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 |
2 | 13 |
|
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 | + """ |
4 | 27 | app = Flask(__name__) |
5 | 28 |
|
| 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 | + |
6 | 73 | @app.route('/') |
7 | 74 | 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 |
9 | 169 |
|
10 | 170 | return app |
11 | 171 |
|
12 | 172 |
|
13 | 173 | # This is "main" for the gunicorn launch and cannot be tested directly |
14 | 174 | 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())) |
16 | 193 |
|
17 | 194 |
|
18 | 195 | # This is "main" for the local launch and can be tested directly |
|
0 commit comments