9db36c2d5b
Build and Push Docker Container / build-and-push (push) Successful in 1m2s
- Accept files-scoped bearer tokens on servicelink RPC calls. - Keep mesh shared-secret auth for trusted internal callers. - Validate CLI auth scopes and reject unsupported values early. - Stop CLI browser login waiting for the full timeout after callback. - Add tests for scope normalization, RPC access, and login callback timing.
114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
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_DEFAULT_SCOPE = 'files'
|
|
CLI_AUTH_ALLOWED_SCOPES = {'all', 'files'}
|
|
|
|
def _normalize_cli_scope(scope: str | None) -> str:
|
|
requested_scope = (scope or CLI_AUTH_DEFAULT_SCOPE).strip() or CLI_AUTH_DEFAULT_SCOPE
|
|
if requested_scope not in CLI_AUTH_ALLOWED_SCOPES:
|
|
raise ValueError(f'unsupported CLI scope: {requested_scope}')
|
|
return requested_scope
|
|
|
|
@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()
|
|
try:
|
|
scope = _normalize_cli_scope(request.args.get('scope'))
|
|
except ValueError as exc:
|
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
|
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'],
|
|
_normalize_cli_scope(login_request.get('scope')),
|
|
)
|
|
|
|
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']
|
|
try:
|
|
scope = _normalize_cli_scope(login_data.get('scope'))
|
|
except ValueError as exc:
|
|
return jsonify({'ok': False, 'error': str(exc)}), 400
|
|
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)
|