appmanager-server 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ from flask import Blueprint
2
+
3
+ api_bp = Blueprint("api", __name__, url_prefix="/api/v1")
4
+
5
+ from appmanager.api import routes # noqa: F401, E402
@@ -0,0 +1,373 @@
1
+ from datetime import datetime, timezone
2
+ from functools import wraps
3
+
4
+ from flask import current_app, jsonify, request
5
+
6
+ from appmanager.admin.app_installer import install_from_git, install_from_zip, uninstall_app
7
+ from appmanager.api import api_bp
8
+ from appmanager.auth.utils import decode_jwt
9
+ from appmanager.database import db
10
+ from appmanager.health import check_app_health
11
+ from appmanager.models import AppHealthLog, AppTelemetryLog, InstalledApp, Role, User
12
+
13
+
14
+ def api_auth_required(f):
15
+ """
16
+ Decorator requiring valid API key (X-API-Key) or Bearer Admin JWT for API endpoints.
17
+ """
18
+
19
+ @wraps(f)
20
+ def decorated_function(*args, **kwargs):
21
+ configured_api_key = current_app.config.get("APPMANAGER_API_KEY")
22
+
23
+ # 1. Check API Key header if configured
24
+ api_key = request.headers.get("X-API-Key")
25
+ if configured_api_key and api_key == configured_api_key:
26
+ return f(*args, **kwargs)
27
+
28
+ # 2. Check Bearer Token (JWT)
29
+ auth_header = request.headers.get("Authorization", "")
30
+ if auth_header.startswith("Bearer "):
31
+ token = auth_header.split(" ")[1]
32
+ # Check if token matches API key
33
+ if configured_api_key and token == configured_api_key:
34
+ return f(*args, **kwargs)
35
+ # Check if token is admin JWT
36
+ payload = decode_jwt(token)
37
+ if payload:
38
+ user = db.session.get(User, payload.get("user_id"))
39
+ if user and user.is_active and user.is_admin():
40
+ return f(*args, **kwargs)
41
+
42
+ # If no API key is set in config and no auth header, in dev/testing check if admin logged in
43
+ if not configured_api_key:
44
+ # Check cookie or JWT
45
+ token = request.cookies.get("appmanager_jwt")
46
+ if token:
47
+ payload = decode_jwt(token)
48
+ if payload:
49
+ user = db.session.get(User, payload.get("user_id"))
50
+ if user and user.is_active and user.is_admin():
51
+ return f(*args, **kwargs)
52
+
53
+ return jsonify(
54
+ {
55
+ "success": False,
56
+ "error": "Unauthorized",
57
+ "message": "Valid X-API-Key or Admin Bearer Token is required.",
58
+ }
59
+ ), 401
60
+
61
+ return decorated_function
62
+
63
+
64
+ @api_bp.route("/health", methods=["GET"])
65
+ def system_health():
66
+ """
67
+ Returns overall AppManager host system health and database connectivity.
68
+ """
69
+ db_ok = True
70
+ db_error = None
71
+ try:
72
+ from sqlalchemy import text
73
+
74
+ db.session.execute(text("SELECT 1"))
75
+ except Exception as e:
76
+ db_ok = False
77
+ db_error = str(e)
78
+
79
+ total_apps = InstalledApp.query.count()
80
+ active_apps = InstalledApp.query.filter_by(is_active=True).count()
81
+
82
+ overall_status = "healthy" if db_ok else "unhealthy"
83
+
84
+ return jsonify(
85
+ {
86
+ "status": overall_status,
87
+ "timestamp": datetime.now(timezone.utc).isoformat(),
88
+ "database": {"connected": db_ok, "error": db_error},
89
+ "apps": {"total": total_apps, "active": active_apps},
90
+ }
91
+ ), 200 if db_ok else 503
92
+
93
+
94
+ @api_bp.route("/apps", methods=["GET"])
95
+ def list_apps():
96
+ """
97
+ Lists all installed sub-applications and their latest health status.
98
+ """
99
+ apps = InstalledApp.query.all()
100
+ results = []
101
+ for a in apps:
102
+ app_dict = a.to_dict()
103
+ latest_health = (
104
+ AppHealthLog.query.filter_by(app_id=a.id)
105
+ .order_by(AppHealthLog.checked_at.desc())
106
+ .first()
107
+ )
108
+ app_dict["latest_health"] = {
109
+ "status": latest_health.status if latest_health else "unknown",
110
+ "response_time_ms": latest_health.response_time_ms if latest_health else None,
111
+ "checked_at": latest_health.checked_at.isoformat()
112
+ if latest_health and latest_health.checked_at
113
+ else None,
114
+ }
115
+ results.append(app_dict)
116
+ return jsonify({"success": True, "apps": results, "count": len(results)}), 200
117
+
118
+
119
+ @api_bp.route("/apps/<slug>", methods=["GET"])
120
+ def get_app(slug):
121
+ """
122
+ Retrieves details for a specific sub-app by slug.
123
+ """
124
+ app_record = InstalledApp.query.filter_by(slug=slug).first()
125
+ if not app_record:
126
+ return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
127
+
128
+ app_dict = app_record.to_dict()
129
+ latest_health = (
130
+ AppHealthLog.query.filter_by(app_id=app_record.id)
131
+ .order_by(AppHealthLog.checked_at.desc())
132
+ .first()
133
+ )
134
+ app_dict["latest_health"] = {
135
+ "status": latest_health.status if latest_health else "unknown",
136
+ "response_time_ms": latest_health.response_time_ms if latest_health else None,
137
+ "checked_at": latest_health.checked_at.isoformat()
138
+ if latest_health and latest_health.checked_at
139
+ else None,
140
+ }
141
+ return jsonify({"success": True, "app": app_dict}), 200
142
+
143
+
144
+ @api_bp.route("/apps/install", methods=["POST"])
145
+ @api_auth_required
146
+ def install_app():
147
+ """
148
+ Installs a sub-app via Git repository URL (JSON payload) or ZIP upload (multipart).
149
+ """
150
+ if request.is_json:
151
+ data = request.get_json() or {}
152
+ repo_url = data.get("repo_url", "").strip()
153
+ name = data.get("name", "").strip()
154
+ slug = data.get("slug", "").strip() or None
155
+ description = data.get("description", "").strip() or None
156
+ entry_point = data.get("entry_point", "").strip() or None
157
+
158
+ if not repo_url or not name:
159
+ return jsonify(
160
+ {"success": False, "error": "Missing required fields: repo_url, name"}
161
+ ), 400
162
+
163
+ try:
164
+ installed = install_from_git(
165
+ repo_url, name, slug=slug, description=description, entry_point=entry_point
166
+ )
167
+ return jsonify(
168
+ {
169
+ "success": True,
170
+ "message": f"App '{installed.name}' installed successfully.",
171
+ "app": installed.to_dict(),
172
+ }
173
+ ), 201
174
+ except Exception as e:
175
+ return jsonify({"success": False, "error": str(e)}), 500
176
+
177
+ elif "zip_file" in request.files:
178
+ zip_file = request.files["zip_file"]
179
+ name = request.form.get("name", "").strip()
180
+ slug = request.form.get("slug", "").strip() or None
181
+ description = request.form.get("description", "").strip() or None
182
+ entry_point = request.form.get("entry_point", "").strip() or None
183
+
184
+ if not name:
185
+ return jsonify({"success": False, "error": "Missing required field 'name'"}), 400
186
+
187
+ try:
188
+ installed = install_from_zip(
189
+ zip_file, name, slug=slug, description=description, entry_point=entry_point
190
+ )
191
+ return jsonify(
192
+ {
193
+ "success": True,
194
+ "message": f"App '{installed.name}' installed from ZIP.",
195
+ "app": installed.to_dict(),
196
+ }
197
+ ), 201
198
+ except Exception as e:
199
+ return jsonify({"success": False, "error": str(e)}), 500
200
+
201
+ return jsonify(
202
+ {
203
+ "success": False,
204
+ "error": "Invalid request format. Send JSON with repo_url or multipart with zip_file.",
205
+ }
206
+ ), 400
207
+
208
+
209
+ @api_bp.route("/apps/<slug>", methods=["DELETE"])
210
+ @api_auth_required
211
+ def delete_app_by_slug(slug):
212
+ """
213
+ Uninstalls an application by slug.
214
+ """
215
+ app_record = InstalledApp.query.filter_by(slug=slug).first()
216
+ if not app_record:
217
+ return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
218
+
219
+ success, msg = uninstall_app(app_record.id)
220
+ if success:
221
+ return jsonify({"success": True, "message": msg}), 200
222
+ return jsonify({"success": False, "error": msg}), 500
223
+
224
+
225
+ @api_bp.route("/apps/<slug>/health-check", methods=["POST"])
226
+ @api_auth_required
227
+ def trigger_health_check(slug):
228
+ """
229
+ Triggers an immediate health check for a sub-app and returns the result.
230
+ """
231
+ app_record = InstalledApp.query.filter_by(slug=slug).first()
232
+ if not app_record:
233
+ return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
234
+
235
+ log = check_app_health(app_record)
236
+ return jsonify(
237
+ {
238
+ "success": True,
239
+ "app_slug": slug,
240
+ "status": log.status,
241
+ "response_time_ms": log.response_time_ms,
242
+ "details": log.details,
243
+ "checked_at": log.checked_at.isoformat() if log.checked_at else None,
244
+ }
245
+ ), 200
246
+
247
+
248
+ @api_bp.route("/apps/<slug>/reload", methods=["POST"])
249
+ @api_auth_required
250
+ def reload_app(slug):
251
+ """
252
+ Invalidates the in-memory WSGI cache for a sub-app to trigger live reload.
253
+ """
254
+ app_record = InstalledApp.query.filter_by(slug=slug).first()
255
+ if not app_record:
256
+ return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
257
+
258
+ if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
259
+ current_app.extensions["appmanager"].clear_cache(slug=slug)
260
+
261
+ return jsonify({"success": True, "message": f"Cache cleared for sub-app '{slug}'."}), 200
262
+
263
+
264
+ @api_bp.route("/metrics", methods=["GET"])
265
+ def get_metrics():
266
+ """
267
+ Returns telemetry metrics summary.
268
+ """
269
+ recent_logs = AppTelemetryLog.query.order_by(AppTelemetryLog.created_at.desc()).limit(100).all()
270
+ metrics_summary = {}
271
+ for log in recent_logs:
272
+ if log.app_slug not in metrics_summary:
273
+ metrics_summary[log.app_slug] = {"total_events": 0, "latest_event": None}
274
+ metrics_summary[log.app_slug]["total_events"] += 1
275
+ if not metrics_summary[log.app_slug]["latest_event"]:
276
+ metrics_summary[log.app_slug]["latest_event"] = {
277
+ "event_type": log.event_type,
278
+ "created_at": log.created_at.isoformat() if log.created_at else None,
279
+ }
280
+
281
+ return jsonify(
282
+ {
283
+ "success": True,
284
+ "timestamp": datetime.now(timezone.utc).isoformat(),
285
+ "metrics": metrics_summary,
286
+ }
287
+ ), 200
288
+
289
+
290
+ @api_bp.route("/roles", methods=["GET"])
291
+ @api_auth_required
292
+ def list_roles_api():
293
+ """
294
+ Returns list of all configured roles and member counts.
295
+ """
296
+ roles = Role.query.order_by(Role.name).all()
297
+ data = []
298
+ for r in roles:
299
+ r_dict = r.to_dict()
300
+ r_dict["member_count"] = User.query.filter_by(role=r.slug).count()
301
+ data.append(r_dict)
302
+ return jsonify({"success": True, "count": len(data), "roles": data})
303
+
304
+
305
+ @api_bp.route("/roles", methods=["POST"])
306
+ @api_auth_required
307
+ def create_role_api():
308
+ """
309
+ Creates a new custom role.
310
+ """
311
+ from appmanager.admin.app_installer import sanitize_slug
312
+
313
+ data = request.get_json() or {}
314
+ name = data.get("name", "").strip()
315
+ slug = data.get("slug", "").strip() or sanitize_slug(name)
316
+ description = data.get("description", "").strip()
317
+
318
+ if not name:
319
+ return jsonify({"error": "Role name is required", "success": False}), 400
320
+
321
+ clean_slug = sanitize_slug(slug)
322
+ existing = Role.query.filter((Role.slug == clean_slug) | (Role.name == name)).first()
323
+ if existing:
324
+ return jsonify(
325
+ {
326
+ "error": f"Role with slug '{clean_slug}' or name '{name}' already exists",
327
+ "success": False,
328
+ }
329
+ ), 409
330
+
331
+ role = Role(name=name, slug=clean_slug, description=description, is_system=False)
332
+ db.session.add(role)
333
+ db.session.commit()
334
+
335
+ return jsonify(
336
+ {
337
+ "success": True,
338
+ "message": f"Role '{role.name}' created successfully",
339
+ "role": role.to_dict(),
340
+ }
341
+ ), 201
342
+
343
+
344
+ @api_bp.route("/roles/<string:slug>", methods=["DELETE"])
345
+ @api_auth_required
346
+ def delete_role_api(slug: str):
347
+ """
348
+ Deletes a custom role.
349
+ """
350
+ role = Role.query.filter_by(slug=slug).first()
351
+ if not role:
352
+ return jsonify({"error": "Role not found", "success": False}), 404
353
+
354
+ if role.is_system:
355
+ return jsonify(
356
+ {"error": f"Cannot delete core system role '{role.name}'", "success": False}
357
+ ), 400
358
+
359
+ # Reassign affected users
360
+ affected = User.query.filter_by(role=role.slug).all()
361
+ for u in affected:
362
+ u.role = "user"
363
+
364
+ db.session.delete(role)
365
+ db.session.commit()
366
+
367
+ return jsonify(
368
+ {
369
+ "success": True,
370
+ "message": f"Role '{role.name}' deleted successfully",
371
+ "reassigned_members": len(affected),
372
+ }
373
+ )
@@ -0,0 +1,101 @@
1
+ """
2
+ Typed per-app configuration helpers.
3
+
4
+ Provides CRUD over the `app_configs` table and a one-time migration from the
5
+ legacy `settings_json` blob. Extensions and the admin console both use these
6
+ helpers so settings stay consistent and queryable.
7
+ """
8
+
9
+ from appmanager.database import db
10
+ from appmanager.models import AppConfig, InstalledApp
11
+
12
+
13
+ def get_config(app_id, key, default=None):
14
+ """Fetch a single config value (decoded)."""
15
+ row = AppConfig.query.filter_by(app_id=app_id, key=key).first()
16
+ if row is None:
17
+ return default
18
+ return row.get_value()
19
+
20
+
21
+ def get_configs(app_id):
22
+ """Fetch all configs for an app as {key: decoded_value}."""
23
+ rows = AppConfig.query.filter_by(app_id=app_id).all()
24
+ return {r.key: r.get_value() for r in rows}
25
+
26
+
27
+ def set_config(app_id, key, value, value_type="json", is_secret=False):
28
+ """Create or update a single config row."""
29
+ row = AppConfig.query.filter_by(app_id=app_id, key=key).first()
30
+ if not row:
31
+ row = AppConfig(app_id=app_id, key=key, value_type=value_type, is_secret=is_secret)
32
+ db.session.add(row)
33
+ row.set_value(value)
34
+ db.session.commit()
35
+ return row
36
+
37
+
38
+ def set_configs(app_id, config_dict, schema=None):
39
+ """
40
+ Bulk upsert configs from a dict. `schema` is an optional list of
41
+ {key, type, default, is_secret} descriptors used to set value_type.
42
+ """
43
+ schema_map = {s.get("key"): s for s in (schema or [])}
44
+ for key, value in config_dict.items():
45
+ desc = schema_map.get(key, {})
46
+ set_config(
47
+ app_id,
48
+ key,
49
+ value,
50
+ value_type=desc.get("type", "json"),
51
+ is_secret=desc.get("is_secret", False),
52
+ )
53
+
54
+
55
+ def seed_defaults(app_id, schema):
56
+ """
57
+ Seed default config rows for an app from a settings_schema manifest.
58
+ Only creates rows that don't already exist (doesn't overwrite user edits).
59
+ """
60
+ for desc in schema or []:
61
+ key = desc.get("key")
62
+ if not key:
63
+ continue
64
+ existing = AppConfig.query.filter_by(app_id=app_id, key=key).first()
65
+ if existing is None:
66
+ row = AppConfig(
67
+ app_id=app_id,
68
+ key=key,
69
+ value_type=desc.get("type", "json"),
70
+ is_secret=desc.get("is_secret", False),
71
+ )
72
+ row.set_value(desc.get("default"))
73
+ db.session.add(row)
74
+ db.session.commit()
75
+
76
+
77
+ def migrate_from_settings_json(app_id=None):
78
+ """
79
+ One-time migration: parse each app's legacy `settings_json` blob and seed
80
+ `app_configs` rows. Idempotent — skips apps that already have config rows.
81
+ Returns number of apps migrated.
82
+ """
83
+ query = InstalledApp.query
84
+ if app_id is not None:
85
+ query = query.filter_by(id=app_id)
86
+ apps = query.all()
87
+ migrated = 0
88
+ for a in apps:
89
+ existing = AppConfig.query.filter_by(app_id=a.id).first()
90
+ if existing is not None:
91
+ continue
92
+ settings = a.get_settings()
93
+ if not settings:
94
+ continue
95
+ for key, value in settings.items():
96
+ row = AppConfig(app_id=a.id, key=str(key), value_type="json")
97
+ row.set_value(value)
98
+ db.session.add(row)
99
+ migrated += 1
100
+ db.session.commit()
101
+ return migrated
@@ -0,0 +1,3 @@
1
+ from appmanager.auth.routes import auth_bp
2
+
3
+ __all__ = ["auth_bp"]
@@ -0,0 +1,77 @@
1
+ import secrets
2
+ import urllib.parse
3
+
4
+ import requests
5
+ from flask import current_app, request, session, url_for
6
+
7
+
8
+ def get_google_auth_url():
9
+ client_id = current_app.config.get("GOOGLE_CLIENT_ID")
10
+ if not client_id:
11
+ return None, "Google Client ID is not configured."
12
+
13
+ state = secrets.token_urlsafe(16)
14
+ session["oauth_state"] = state
15
+
16
+ redirect_uri = url_for("auth.google_callback", _external=True)
17
+
18
+ params = {
19
+ "client_id": client_id,
20
+ "redirect_uri": redirect_uri,
21
+ "response_type": "code",
22
+ "scope": "openid email profile",
23
+ "state": state,
24
+ "access_type": "offline",
25
+ "prompt": "select_account",
26
+ }
27
+
28
+ auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urllib.parse.urlencode(params)}"
29
+ return auth_url, None
30
+
31
+
32
+ def process_google_callback():
33
+ client_id = current_app.config.get("GOOGLE_CLIENT_ID")
34
+ client_secret = current_app.config.get("GOOGLE_CLIENT_SECRET")
35
+
36
+ if not client_id or not client_secret:
37
+ return None, "Google Client ID or Secret missing."
38
+
39
+ state = request.args.get("state")
40
+ saved_state = session.pop("oauth_state", None)
41
+
42
+ if not state or state != saved_state:
43
+ return None, "Invalid OAuth state parameter."
44
+
45
+ code = request.args.get("code")
46
+ if not code:
47
+ return None, "Authorization code not provided by Google."
48
+
49
+ redirect_uri = url_for("auth.google_callback", _external=True)
50
+
51
+ # Exchange code for token
52
+ token_url = "https://oauth2.googleapis.com/token"
53
+ token_data = {
54
+ "code": code,
55
+ "client_id": client_id,
56
+ "client_secret": client_secret,
57
+ "redirect_uri": redirect_uri,
58
+ "grant_type": "authorization_code",
59
+ }
60
+
61
+ token_res = requests.post(token_url, data=token_data, timeout=10)
62
+ if token_res.status_code != 200:
63
+ return None, f"Failed to exchange token with Google: {token_res.text}"
64
+
65
+ tokens = token_res.json()
66
+ access_token = tokens.get("access_token")
67
+
68
+ # Get User Info
69
+ userinfo_url = "https://www.googleapis.com/oauth2/v3/userinfo"
70
+ userinfo_res = requests.get(
71
+ userinfo_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=10
72
+ )
73
+ if userinfo_res.status_code != 200:
74
+ return None, "Failed to retrieve user profile from Google."
75
+
76
+ user_data = userinfo_res.json()
77
+ return user_data, None