- Notifications
You must be signed in to change notification settings - Fork 1
fix: OAuth2 token encoding and /auth/info route#49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base:master
Are you sure you want to change the base?
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
4a882b52185149f1547b1526553d343b88f0831ab79e3d19c33065695fd3be40d667c81c741c9File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2,7 +2,9 @@ | ||
| from __future__ import annotations | ||
| import contextlib | ||
| import os | ||
| import re | ||
| import secrets | ||
| import string | ||
| from pathlib import Path | ||
| @@ -176,3 +178,83 @@ def rotate_env_var( | ||
| audit.log("rotate", key, env_file=str(env_file)) | ||
| return True, new_value | ||
| def _atomic_write(path: Path, content: str) -> None: | ||
| """Write *content* to *path* atomically (temp file + os.replace). | ||
| A crash mid-write must never leave a truncated or half-rotated .env file. | ||
| """ | ||
| tmp = path.with_name(f".{path.name}.rotate-tmp-{os.getpid()}") | ||
| try: | ||
| with open(tmp, "w") as f: | ||
| f.write(content) | ||
| f.flush() | ||
| os.fsync(f.fileno()) | ||
| os.replace(tmp, path) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the configured environment file is a symlink, the preceding read follows the link but Useful? React with 👍 / 👎. | ||
| except BaseException: | ||
| with contextlib.suppress(OSError): | ||
| os.unlink(tmp) | ||
| raise | ||
| def rotate_env_file( | ||
| env_file: str | Path, | ||
| *, | ||
| length: int = 32, | ||
| exclude: set[str] | None = None, | ||
| dry_run: bool = False, | ||
| audit: AuditLogger | None = None, | ||
| ) -> dict[str, str]: | ||
| """Rotate every variable in a .env file with ONE atomic rewrite. | ||
| Args: | ||
| env_file: Path to the .env file. | ||
| length: Length of generated secrets. | ||
| exclude: Keys to leave untouched. | ||
| dry_run: If True, don't modify the file. | ||
| audit: Optional audit logger (one entry per rotated key). | ||
| Returns: | ||
| Mapping of key -> new value for every rotated key. | ||
| """ | ||
| from dotenv import dotenv_values | ||
| env_file = Path(env_file) | ||
| exclude = exclude or set() | ||
| env_vars = dotenv_values(env_file) | ||
| plan: dict[str, str] = {} | ||
| for key, value in env_vars.items(): | ||
| if key in exclude or value is None: | ||
| continue | ||
| plan[key] = rotate_value(key, value, length=length) | ||
| if dry_run or not plan: | ||
| return plan | ||
| lines = env_file.read_text().split("\n") | ||
| seen: set[str] = set() | ||
| out_lines: list[str] = [] | ||
| key_line = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=") | ||
| for line in lines: | ||
| m = key_line.match(line) | ||
| if m and m.group(1) in plan and m.group(1) not in seen: | ||
| key = m.group(1) | ||
| seen.add(key) | ||
Comment on lines
+242
to
+244
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a file containing the same key more than once, such as Useful? React with 👍 / 👎. | ||
| new_value = plan[key] | ||
| if any(c in new_value for c in " #'\"\n\t"): | ||
| safe = new_value.replace("\\", "\\\\").replace('"', '\\"') | ||
| out_lines.append(f'{key}="{safe}"') | ||
| else: | ||
| out_lines.append(f"{key}={new_value}") | ||
| else: | ||
| out_lines.append(line) | ||
| _atomic_write(env_file, "\n".join(out_lines)) | ||
| if audit: | ||
| for key in plan: | ||
| audit.log("rotate", key, env_file=str(env_file)) | ||
| return plan | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -19,7 +19,6 @@ | ||
| import base64 | ||
| import json | ||
| import os | ||
| import secrets as _secrets | ||
| import time | ||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||
| from pathlib import Path | ||
| @@ -69,36 +68,6 @@ def _send_error(self, status: int, message: str) -> None: | ||
| """Send a JSON error payload.""" | ||
| self._send_json({"error": message}, status=status) | ||
| def _check_auth(self) -> bool: | ||
| """Validate the Bearer token if API auth is enabled. | ||
| Returns True if the request is authorized (or auth is disabled). | ||
| Returns False if auth is required but missing/invalid (and sends 401). | ||
| """ | ||
| if not self.api_key: | ||
| # Auth not configured — allow all requests | ||
| return True | ||
| auth_header = self.headers.get("Authorization", "") | ||
| if not auth_header: | ||
| self._send_error(401, "Unauthorized: valid Bearer token required") | ||
| return False | ||
| token = auth_header[len("Bearer ") :] if auth_header.startswith("Bearer ") else auth_header | ||
| if not token or not token.strip(): | ||
| self._send_error(401, "Unauthorized: valid Bearer token required") | ||
| return False | ||
| if ( | ||
| _secrets.compare_digest(token.strip(), self.api_key) | ||
| if self.api_key | ||
| else _secrets.compare_digest(token.strip(), "") | ||
| ): | ||
| return True | ||
| self._send_error(401, "Unauthorized: valid Bearer token required") | ||
| return False | ||
| # ── Routing ────────────────────────────────────────────────────────────── | ||
| def _check_bearer_token(self) -> bool: | ||
| @@ -330,6 +299,9 @@ def do_GET(self) -> None: # noqa: N802 -- stdlib naming convention | ||
| if path == "/health": | ||
| # /health is always accessible (useful for load balancers) | ||
| self._handle_health() | ||
| elif path == "/auth/info": | ||
| # /auth/info is always accessible so clients can discover auth methods | ||
| self._handle_auth_info() | ||
Coding-Dev-Tools marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| elif path == "/secrets": | ||
| if not self._check_auth(): | ||
| return | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the source
.envis secured with mode 0600 and the process has a typical 0022 umask, opening this new temporary file creates it as 0644;os.replace()then installs those permissions on the rotated.env. A successfulrotate-alltherefore makes every newly generated secret readable by other local users, so copy the original file mode to the temporary file before replacing it.Useful? React with 👍 / 👎.