from __future__ import annotations import secrets from urllib.parse import urlencode from my_modules.app.setup import LIMITER, cache from quart import Blueprint, jsonify, redirect, request, session, url_for from quart_common.web.auth import get_auth_token from quart_common.web.wide_event import add_wide_event_context from quart import current_app cli_auth_bp = Blueprint('cli_auth', __name__) CLI_CODE_TTL_SECONDS = 300 @cli_auth_bp.get('/auth/cli/login') @LIMITER.limit('10 per minute;60 per hour;') async def cli_login(): redirect_uri = request.args.get('redirect_uri', '').strip() state = request.args.get('state', '').strip() scope = request.args.get('scope', 'files').strip() or 'files' add_wide_event_context(auth={'method': 'cli_browser', 'operation': 'cli_login_start'}) if not redirect_uri or not state: return jsonify({'ok': False, 'error': 'redirect_uri and state are required'}), 400 user = session.get('user') if not user: session['cli_login_request'] = {'redirect_uri': redirect_uri, 'state': state, 'scope': scope} session['post_login_redirect'] = url_for('cli_auth.cli_login_complete') return redirect(url_for('auth_login.login')) return await _redirect_with_cli_code(user['sub'], redirect_uri, state, scope) @cli_auth_bp.get('/auth/cli/complete') @LIMITER.limit('10 per minute;60 per hour;') async def cli_login_complete(): user = session.get('user') login_request = session.pop('cli_login_request', None) if not user or not login_request: return jsonify({'ok': False, 'error': 'no pending CLI login'}), 400 return await _redirect_with_cli_code( user['sub'], login_request['redirect_uri'], login_request['state'], login_request.get('scope') or 'files', ) async def _redirect_with_cli_code(user_id: str, redirect_uri: str, state: str, scope: str): code = secrets.token_urlsafe(32) await cache.set( f'cli:login:{code}', {'user_id': user_id, 'scope': scope}, ttl=CLI_CODE_TTL_SECONDS, ) separator = '&' if '?' in redirect_uri else '?' query = urlencode({'code': code, 'state': state}) return redirect(f'{redirect_uri}{separator}{query}') @cli_auth_bp.post('/api/cli/token') @LIMITER.limit('20 per minute;200 per hour;') async def cli_token_exchange(): payload = await request.get_json(silent=True) or {} code = str(payload.get('code', '')).strip() if not code: return jsonify({'ok': False, 'error': 'code is required'}), 400 login_data = await cache.get(f'cli:login:{code}') if not login_data: return jsonify({'ok': False, 'error': 'invalid or expired code'}), 400 await cache.delete(f'cli:login:{code}') user_id = login_data['user_id'] scope = login_data.get('scope') or 'files' token_data = await current_app.convex.add_refresh_token( token_name='nanoshare-cli', user_id=user_id, scope=scope, enabled=True, ) refresh_token = token_data.get('refresh_token') if isinstance(token_data, dict) else None if not refresh_token: return jsonify({'ok': False, 'error': 'refresh token creation failed'}), 500 return jsonify({'ok': True, 'refresh_token': refresh_token, 'scope': scope}) @cli_auth_bp.post('/token/refresh') @LIMITER.limit('60 per minute;1000 per hour;') async def token_refresh(): refresh_token = await get_auth_token() if not refresh_token: response = jsonify({'ok': False, 'error': 'refresh token is missing'}) response.headers['WWW-Authenticate'] = 'Bearer' return response, 401 token, refresh_id, user = await current_app.convex.generate_new_access_token(refresh_token) if not isinstance(token, dict) or not token.get('access_token'): return jsonify(token), 401 return jsonify(token)