File size: 9,641 Bytes
79cf149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""One-time Google Drive authorisation, and a health check for the stored grant.

Run once to mint a refresh token:

    venv/Scripts/python.exe -m scripts.gdrive_auth

The browser opens Google's consent screen, the code comes back to a loopback
server on this machine, and the refresh token is written to ``.gdrive_token.json``
(git-ignored). The server picks it up from there on its next request -- nothing
has to be pasted into an environment variable.

Check an existing grant without touching the consent screen:

    venv/Scripts/python.exe -m scripts.gdrive_auth --check

Why this exists at all: access tokens are refreshed silently by
``storage/gdrive.py``, but a *refresh* token can only be minted with a human at
the consent screen. Google issues refresh tokens with a 7-day life while the
OAuth app is in Testing status, so publish the consent screen to Production and
this command becomes a once-ever step rather than a weekly chore.
"""

import argparse
import http.server
import logging
import os
from pathlib import Path
import socket
import sys
import threading
import time
import urllib.parse
import urllib.request
import webbrowser

from dotenv import load_dotenv

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from scripts import cli  # noqa: E402
from storage.gdrive import (  # noqa: E402
    SCOPE,
    DriveAuthError,
    DriveError,
    DriveStore,
    _stored_token,
    _token_file,
    save_refresh_token,
)

AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"

logger = logging.getLogger("gdrive_auth")

SETUP_STEPS = """Set GDRIVE_CLIENT_ID and GDRIVE_CLIENT_SECRET first.

  1. https://console.cloud.google.com/ -> new project
  2. APIs & Services -> Library -> enable 'Google Drive API'
  3. OAuth consent screen -> External -> add yourself as a user, then
     PUBLISH it (an app left in Testing expires refresh tokens after 7 days)
  4. Credentials -> Create OAuth client ID -> Desktop app
  5. Put the id and secret in .env as GDRIVE_CLIENT_ID /
     GDRIVE_CLIENT_SECRET, then run this again
"""


class _CallbackHandler(http.server.BaseHTTPRequestHandler):
    """Captures the single redirect Google makes back to this machine."""

    code = None
    error = None

    def do_GET(self) -> None:  # noqa: N802 - name fixed by BaseHTTPRequestHandler
        query = urllib.parse.urlparse(self.path).query
        params = urllib.parse.parse_qs(query)
        _CallbackHandler.code = (params.get("code") or [None])[0]
        _CallbackHandler.error = (params.get("error") or [None])[0]

        body = (
            b"<html><body style='font-family:sans-serif;padding:2rem'>"
            b"<h2>GrowNXT &mdash; Google Drive authorised</h2>"
            b"<p>You can close this tab and return to the terminal.</p>"
            b"</body></html>"
            if _CallbackHandler.code
            else b"<html><body><h2>Authorisation failed</h2></body></html>"
        )
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args) -> None:
        """Silences the default stderr access log."""


def _free_port() -> int:
    """Returns a port the loopback callback server can bind."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


def _post_form(url: str, fields: dict) -> dict:
    """POSTs a form and returns the parsed JSON body."""
    import json

    data = urllib.parse.urlencode(fields).encode("utf-8")
    request = urllib.request.Request(url, data=data, method="POST")
    request.add_header("Content-Type", "application/x-www-form-urlencoded")
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            return json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", "replace")
        raise SystemExit("Token exchange failed (HTTP %s): %s" % (exc.code, detail))


def authorise(client_id: str, client_secret: str) -> str:
    """Runs the consent flow and returns a refresh token.

    Args:
        client_id: OAuth client id of a Desktop-type client.
        client_secret: Matching client secret.

    Returns:
        str: The refresh token.
    """
    port = _free_port()
    redirect_uri = "http://127.0.0.1:%d/" % port

    params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": SCOPE,
        # Without these two Google returns no refresh token on a repeat consent.
        "access_type": "offline",
        "prompt": "consent",
    }
    auth_url = "%s?%s" % (AUTH_ENDPOINT, urllib.parse.urlencode(params))

    server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
    server.timeout = 5

    def serve_until_answered(deadline: float) -> None:
        """Serves requests until the redirect arrives, or time runs out.

        Handling a single request is not enough: a browser may prefetch
        `/favicon.ico` against the same port, which would consume the one
        request and leave the authorisation code unread.
        """
        while time.time() < deadline:
            if _CallbackHandler.code or _CallbackHandler.error:
                return
            server.handle_request()

    _CallbackHandler.code = None
    _CallbackHandler.error = None
    deadline = time.time() + 300
    thread = threading.Thread(target=serve_until_answered, args=(deadline,), daemon=True)
    thread.start()

    print("Opening Google's consent screen. If nothing opens, visit:\n\n%s\n" % auth_url)
    try:
        webbrowser.open(auth_url)
    except Exception:  # noqa: BLE001 - a headless box just uses the printed URL
        pass

    thread.join(timeout=305)
    server.server_close()

    if _CallbackHandler.error:
        raise SystemExit("Google returned an error: %s" % _CallbackHandler.error)
    if not _CallbackHandler.code:
        raise SystemExit("No authorisation code received within 5 minutes.")

    payload = _post_form(TOKEN_ENDPOINT, {
        "code": _CallbackHandler.code,
        "client_id": client_id,
        "client_secret": client_secret,
        "redirect_uri": redirect_uri,
        "grant_type": "authorization_code",
    })

    refresh_token = payload.get("refresh_token")
    if not refresh_token:
        raise SystemExit(
            "Google returned no refresh token. Revoke this app's access at "
            "https://myaccount.google.com/permissions and run this again."
        )
    return refresh_token


def _report_missing() -> None:
    """Names which credential is absent, without printing any of their values."""
    have_id = bool(os.getenv("GDRIVE_CLIENT_ID", "").strip())
    have_secret = bool(os.getenv("GDRIVE_CLIENT_SECRET", "").strip())
    stored = _stored_token()
    have_token = bool(stored or os.getenv("GDRIVE_REFRESH_TOKEN", "").strip())

    for label, present in (
        ("GDRIVE_CLIENT_ID", have_id),
        ("GDRIVE_CLIENT_SECRET", have_secret),
        ("refresh token", have_token),
    ):
        print("  [%s] %s" % ("ok " if present else "   ", label))

    if have_id and have_secret and not have_token:
        print(
            "\nThe client credentials are in place; what is missing is the "
            "refresh token,\nwhich only the consent screen can mint. Run:\n\n"
            "    venv/Scripts/python.exe -m scripts.gdrive_auth\n"
        )


def check() -> int:
    """Verifies the stored grant still mints access tokens.

    Returns:
        int: Process exit code.
    """
    try:
        store = DriveStore()
    except DriveAuthError:
        print("Google Drive is not fully configured:\n")
        _report_missing()
        return 1

    try:
        store.access_token()
    except DriveAuthError as exc:
        print("GRANT REJECTED: %s" % exc)
        return 1
    except DriveError as exc:
        print("could not reach Google: %s" % exc)
        return 1

    print("ok: the stored refresh token still works (token file: %s)" % _token_file())
    if store.folder_id:
        print("uploads target folder %s" % store.folder_id)
    else:
        print("uploads land in the account root (set GDRIVE_FOLDER_ID to change that)")
    return 0


def _parser() -> argparse.ArgumentParser:
    """Builds the argument parser."""
    p = argparse.ArgumentParser(description="Authorise GrowNXT against Google Drive.")
    p.add_argument("--check", action="store_true",
                   help="Verify the stored refresh token instead of re-authorising.")
    p.add_argument("--client-id", help="Overrides GDRIVE_CLIENT_ID.")
    p.add_argument("--client-secret", help="Overrides GDRIVE_CLIENT_SECRET.")
    return p


def main() -> int:
    """Runs the consent flow, or checks the grant already stored."""
    args = _parser().parse_args()
    cli.setup(logging.INFO, cli.PLAIN)
    load_dotenv()

    if args.check:
        return check()

    client_id = args.client_id or os.getenv("GDRIVE_CLIENT_ID", "").strip()
    secret = args.client_secret or os.getenv("GDRIVE_CLIENT_SECRET", "").strip()
    if not (client_id and secret):
        print(SETUP_STEPS, file=sys.stderr)
        return 1

    path = save_refresh_token(authorise(client_id, secret))
    print("\nRefresh token saved to %s" % path)
    print("The server reads it from there; no restart needed for the next request.")
    return check()


if __name__ == "__main__":
    sys.exit(main())