Files
universal-autoreg/providers/gh_device_flow.py

177 lines
6.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""gh_device_auth — device-flow авторизация gh CLI через уже залогиненную GHSession.
1. gh auth login --web печатает one-time code XXXX-XXXX и поллит github.com/login/oauth/access_token
2. Мы: GHSession.login(account) -> GET /login/device -> POST user_code -> authorize-confirm
3. gh получает gho_-токен (scopes: repo,read:org,gist) и пишет в свой keyring/hosts.yml
4. gh auth token -> токен; сохраняем в .gh_push_tokens.json
Usage: python gh_device_auth.py
"""
import json
import os
import re
import subprocess
import sys
import threading
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib'))
from gh_session import GHSession, find_form, form_fields
POOL = 'C:/Users/User/tmp/gh_accounts.json'
BANK = 'C:/Users/User/tmp/beat_autoreg/accounts.jsonl'
OUT = 'C:/Users/User/tmp/beat_autoreg/.gh_push_tokens.json'
GH = 'https://github.com'
def pick_account():
pool = {a['login']: a for a in json.load(open(POOL, encoding='utf-8'))}
recs = [json.loads(l) for l in open(BANK, encoding='utf-8') if l.strip()]
cands = [r['gh_login'] for r in recs if r.get('access_token') and r['gh_login'] in pool]
cands = list(dict.fromkeys(cands))[::-1]
for login in cands:
return pool[login]
return None
def approve_device(s, code):
"""Device-flow: select_account -> user-code-N посимвольно -> confirmation -> consent."""
# 1. select_account
st, h, body = s.get(GH + '/login/device/select_account')
if st != 200:
return False, f'select_account http={st}'
form = find_form(body, 'select_account')
fields = form_fields(form) if form else {}
st2, h2, body2 = s.post_form(GH + '/login/device/select_account', fields)
loc2 = h2.get('Location', '')
if st2 in (301, 302) and loc2:
u = loc2 if loc2.startswith('http') else GH + loc2
st2, h2, body2 = s.get(u)
# 2. confirmation form: user-code-0..8 посимвольно (включая дефис)
cform = find_form(body2, '/login/device/confirmation')
if not cform:
return False, f'no confirmation form (http={st2}, len={len(body2)})'
fields = form_fields(cform)
chars = list(code) # 'ABCF-A8AA' -> 9 символов
for i, ch in enumerate(chars):
fields[f'user-code-{i}'] = ch
if 'commit' in fields and not fields['commit']:
fields['commit'] = 'Continue'
st3, h3, body3 = s.post_form(GH + '/login/device/confirmation', fields)
loc3 = h3.get('Location', '')
if st3 in (301, 302) and loc3:
u = loc3 if loc3.startswith('http') else GH + loc3
st3, h3, body3 = s.get(u)
if 'Device activated' in body3 or 'Congratulations' in body3:
return True, 'activated (no consent)'
open('C:/Users/User/tmp/beat_autoreg/consent_debug.html', 'w', encoding='utf-8').write(body3)
# 3. consent страница gh CLI OAuth app: форма с authenticity_token + Authorize
m = re.search(r'name="authenticity_token"[^>]*value="([^"]+)"', body3)
if not m:
return False, f'no consent token (http={st3}, len={len(body3)})'
aform = None
for mm in re.finditer(r'<form[^>]*>.*?</form>', body3, re.S):
if 'authenticity_token' in mm.group(0):
aform = mm.group(0)
break
action = GH + '/login/oauth/authorize'
if aform:
am = re.search(r'action="([^"]+)"', aform)
if am:
action = am.group(1)
if not action.startswith('http'):
action = GH + action
fields = form_fields(aform)
else:
fields = {'authenticity_token': m.group(1)}
fields['authenticity_token'] = m.group(1)
fields['authorize'] = '1' # КРИТИЧНО: кнопка Authorize (0 = Deny = access_denied)
st4, h4, body4 = s.post_form(action, fields)
loc4 = h4.get('Location', '')
if st4 in (301, 302) and loc4:
u = loc4 if loc4.startswith('http') else GH + loc4
st4, h4, body4 = s.get(u)
if 'Device activated' in body4 or 'Congratulations' in body4 or st4 == 200:
return True, f'consent ok http={st4}'
return False, f'consent unclear http={st4} len={len(body4)}'
def main():
if len(sys.argv) > 1:
pool = {a['login']: a for a in json.load(open(POOL, encoding='utf-8'))}
acc = pool.get(sys.argv[1])
else:
acc = pick_account()
if not acc:
print('NO_ACCOUNT')
return
login = acc['login']
print('account:', login)
# 1. GHSession login
s = GHSession()
ok, msg = s.login(login, acc['password'], acc.get('totp'))
print('gh login:', ok, msg)
if not ok:
print('LOGIN_FAIL')
return
# 2. gh auth login --web в фоне (pty через subprocess + pipes)
env = dict(os.environ)
env['GH_PROMPT_DISABLED'] = '1'
proc = subprocess.Popen(
['gh', 'auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding='utf-8', errors='replace', env=env)
code = None
buf = ''
deadline = time.time() + 60
while time.time() < deadline:
line = proc.stdout.readline()
if not line:
time.sleep(0.2)
continue
buf += line
print('gh:', line.strip(), flush=True)
m = re.search(r'([A-Z0-9]{4}-[A-Z0-9]{4})', line)
if m:
code = m.group(1)
break
if proc.poll() is not None:
break
if not code:
print('NO_CODE from gh. buf:', buf[-300:])
proc.kill()
return
print('device code:', code)
# Enter в gh (откроет "браузер" — упадёт молча, поллинг продолжится)
try:
proc.stdin.write('\n')
proc.stdin.flush()
except Exception:
pass
# 3. approve в GHSession
ok2, info = approve_device(s, code)
print('approve:', ok2, info)
if not ok2:
proc.kill()
return
# 4. ждать завершения gh (поллинг access_token)
try:
out, _ = proc.communicate(timeout=60)
print('gh finished:', (out or '')[-300:])
except subprocess.TimeoutExpired:
proc.kill()
print('gh timeout after approve')
# 5. gh auth token
r = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True, timeout=30)
tok = r.stdout.strip()
if tok.startswith(('gho_', 'ghu_', 'ghp_')):
json.dump([[tok, login]], open(OUT, 'w'))
print('TOKEN_OK len', len(tok), '-> ', OUT)
else:
print('TOKEN_FAIL:', r.stderr[:200])
if __name__ == '__main__':
main()