blob: 7c082b267e5d0480a0f04c2dbfc70b7e5ec320c3 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Shared OAuth credential handling for the ``oauth-draft-*`` commands.
The three commands (``oauth-draft-setup``, ``oauth-draft-create``,
``oauth-draft-mark-read``) all need to:
- locate a credentials JSON on disk (CLI flag → env var → default path);
- parse it into a typed ``Credentials`` record;
- exchange the long-lived refresh token for a short-lived access token.
This module owns those primitives so the command modules can stay thin.
"""
from __future__ import annotations
import dataclasses
import json
import os
import pathlib
import urllib.error
import urllib.parse
import urllib.request
TOKEN_URL = "https://oauth2.googleapis.com/token"
GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me"
# Default location of the credentials file. Was historically
# ``~/.config/airflow-s/gmail-oauth.json`` when this tool lived in the
# Apache Airflow security tracker repo; renamed to
# ``apache-steward`` here as the framework was generalised. Existing
# adopters who still have the file at the old path can either move it
# or set ``$GMAIL_OAUTH_CREDENTIALS`` (or pass ``--credentials``).
DEFAULT_CREDENTIALS_DIR = pathlib.Path.home() / ".config" / "apache-steward"
DEFAULT_CREDENTIALS_PATH = DEFAULT_CREDENTIALS_DIR / "gmail-oauth.json"
@dataclasses.dataclass
class Credentials:
"""OAuth credentials loaded from disk.
``from_address`` is required by ``oauth-draft-create`` (it is the
``From:`` header baked into outgoing drafts) but not by
``oauth-draft-mark-read``. Loaders that do not need it pass
``require_from_address=False`` to :meth:`load`.
"""
client_id: str
client_secret: str
refresh_token: str
from_address: str | None = None
@classmethod
def load(cls, path: pathlib.Path, *, require_from_address: bool = True) -> Credentials:
data = json.loads(path.read_text())
required = ["client_id", "client_secret", "refresh_token"]
if require_from_address:
required.append("from_address")
missing = [k for k in required if not data.get(k)]
if missing:
raise SystemExit(
f"{path}: missing required fields: {', '.join(missing)}. "
f"See tools/gmail/oauth-draft/README.md for the expected shape."
)
return cls(
client_id=data["client_id"],
client_secret=data["client_secret"],
refresh_token=data["refresh_token"],
from_address=data.get("from_address"),
)
def locate_credentials(explicit: str | None) -> pathlib.Path:
"""Resolve the credentials path: ``--credentials`` → env → default.
Raises ``SystemExit`` with a helpful list of paths tried if none
of the candidates exists.
"""
candidates: list[str | None] = [
explicit,
os.environ.get("GMAIL_OAUTH_CREDENTIALS"),
str(DEFAULT_CREDENTIALS_PATH),
]
for c in candidates:
if not c:
continue
p = pathlib.Path(c).expanduser()
if p.is_file():
return p
raise SystemExit(
"No Gmail OAuth credentials found. Tried: "
+ ", ".join(str(pathlib.Path(c).expanduser()) for c in candidates if c)
+ ". See tools/gmail/oauth-draft/README.md."
)
def refresh_access_token(creds: Credentials) -> str:
"""Trade the long-lived refresh token for a ~1h access token."""
body = urllib.parse.urlencode(
{
"client_id": creds.client_id,
"client_secret": creds.client_secret,
"refresh_token": creds.refresh_token,
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(TOKEN_URL, data=body, method="POST")
try:
with urllib.request.urlopen(req, timeout=15) as r:
payload = json.loads(r.read())
except urllib.error.HTTPError as e:
raise SystemExit(f"OAuth token refresh failed ({e.code}): {e.read().decode(errors='replace')}") from e
token = payload.get("access_token")
if not token:
raise SystemExit(f"OAuth token refresh returned no access_token: {payload}")
return str(token)