pygitweb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. pygitweb/__init__.py +22 -0
  2. pygitweb/__meta__.py +9 -0
  3. pygitweb/actions.py +1303 -0
  4. pygitweb/addproject_permissions_test.py +114 -0
  5. pygitweb/api/__init__.py +0 -0
  6. pygitweb/api/action.py +20 -0
  7. pygitweb/api/plugins/__init__.py +0 -0
  8. pygitweb/api/plugins/project_zip.py +71 -0
  9. pygitweb/api/project.py +67 -0
  10. pygitweb/api/subpage.py +22 -0
  11. pygitweb/auth.py +387 -0
  12. pygitweb/auth.schema.json +106 -0
  13. pygitweb/auth_config.py +243 -0
  14. pygitweb/auth_oauth.py +343 -0
  15. pygitweb/auth_test.py +390 -0
  16. pygitweb/boards_permissions_test.py +113 -0
  17. pygitweb/change_queue.py +110 -0
  18. pygitweb/change_queue_routes_test.py +146 -0
  19. pygitweb/change_queue_test.py +91 -0
  20. pygitweb/comments_permissions_test.py +140 -0
  21. pygitweb/config.py +185 -0
  22. pygitweb/conftest.py +43 -0
  23. pygitweb/dependencies.py +87 -0
  24. pygitweb/formatting.py +182 -0
  25. pygitweb/formatting_test.py +68 -0
  26. pygitweb/git_helpers.py +454 -0
  27. pygitweb/git_helpers_test.py +223 -0
  28. pygitweb/gravatar.py +16 -0
  29. pygitweb/gravatar_test.py +10 -0
  30. pygitweb/hooks_install.py +278 -0
  31. pygitweb/hooks_install_test.py +363 -0
  32. pygitweb/hooks_permissions_test.py +116 -0
  33. pygitweb/main.py +887 -0
  34. pygitweb/merge_requests.py +230 -0
  35. pygitweb/merge_requests_test.py +85 -0
  36. pygitweb/mr_permissions_test.py +148 -0
  37. pygitweb/password_hash.py +74 -0
  38. pygitweb/password_hash_test.py +20 -0
  39. pygitweb/permissions.py +209 -0
  40. pygitweb/permissions_test.py +86 -0
  41. pygitweb/plugin_loader.py +83 -0
  42. pygitweb/projects.py +213 -0
  43. pygitweb/projects_test.py +186 -0
  44. pygitweb/sessions.py +73 -0
  45. pygitweb/settings.py +412 -0
  46. pygitweb/settings.schema.json +73 -0
  47. pygitweb/settings_config.py +122 -0
  48. pygitweb/settings_config_test.py +101 -0
  49. pygitweb/settings_permissions_test.py +76 -0
  50. pygitweb/static/blob-view.js +133 -0
  51. pygitweb/static/board.js +839 -0
  52. pygitweb/static/hook-install.js +53 -0
  53. pygitweb/static/icons/cloud-down.svg +1 -0
  54. pygitweb/static/icons/cloud-exclamation.svg +1 -0
  55. pygitweb/static/icons/cloud-filled.svg +1 -0
  56. pygitweb/static/icons/cloud-storm.svg +1 -0
  57. pygitweb/static/icons/cloud-up.svg +1 -0
  58. pygitweb/static/icons/cloud.svg +1 -0
  59. pygitweb/static/logo.svg +44 -0
  60. pygitweb/static/main.css +1210 -0
  61. pygitweb/static/navbar-auth.js +82 -0
  62. pygitweb/static/readme-render.js +174 -0
  63. pygitweb/static/search-page.js +139 -0
  64. pygitweb/static/summary-ref-switcher.js +130 -0
  65. pygitweb/static/themes/solarized.css +34 -0
  66. pygitweb/tasks.py +595 -0
  67. pygitweb/tasks_permissions_test.py +137 -0
  68. pygitweb/tasks_routes_test.py +430 -0
  69. pygitweb/templates/addproject.html +174 -0
  70. pygitweb/templates/blobdiff.html +57 -0
  71. pygitweb/templates/board.html +31 -0
  72. pygitweb/templates/board_card.html +44 -0
  73. pygitweb/templates/commitdiff.html +57 -0
  74. pygitweb/templates/hooks_summary_cell.html +37 -0
  75. pygitweb/templates/login.html +37 -0
  76. pygitweb/templates/merge_request_summary_cell.html +18 -0
  77. pygitweb/templates/merge_request_tag.html +90 -0
  78. pygitweb/templates/pagination.html +44 -0
  79. pygitweb/templates/preamble.html +89 -0
  80. pygitweb/templates/project_settings.html +13 -0
  81. pygitweb/templates/pygit2_settings.html +12 -0
  82. pygitweb/templates/pygitweb_settings.html +12 -0
  83. pygitweb/templates/search.html +77 -0
  84. pygitweb/templates/settings_form_field.html +27 -0
  85. pygitweb/templates/table.html +15 -0
  86. pygitweb/templates/user_profile.html +10 -0
  87. pygitweb/templates_env.py +21 -0
  88. pygitweb/timeline_cache.py +243 -0
  89. pygitweb/validation.py +75 -0
  90. pygitweb/validation_test.py +167 -0
  91. pygitweb-0.1.0.dist-info/METADATA +165 -0
  92. pygitweb-0.1.0.dist-info/RECORD +95 -0
  93. pygitweb-0.1.0.dist-info/WHEEL +5 -0
  94. pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. pygitweb-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,106 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "description": "",
4
+ "type": "object",
5
+ "properties": {
6
+ "salt": {
7
+ "type": "string",
8
+ "pattern": "^[0-9a-fA-F]{16}$",
9
+ "description": "16-character hex salt applied to local password hashes."
10
+ },
11
+ "auth_mode": {
12
+ "type": "string",
13
+ "pattern": "^(oauth|local|both)$"
14
+ },
15
+ "oauth_provider": {
16
+ "type": "string",
17
+ "pattern": "^(google|github)?$"
18
+ },
19
+ "oauth_client_id": {
20
+ "type": "string",
21
+ "minLength": 0
22
+ },
23
+ "oauth_client_secret": {
24
+ "type": "string",
25
+ "minLength": 0
26
+ },
27
+ "oauth_redirect_uri": {
28
+ "type": "string",
29
+ "minLength": 0
30
+ },
31
+ "oauth_username_from": {
32
+ "type": "string",
33
+ "pattern": "^(sub|email|preffered_username|login|name)$"
34
+ },
35
+ "oauth_allowed_emails": {
36
+ "type": "array",
37
+ "description": "OAuth sign-in allowlist. Empty allows all. Entries support fnmatch globs (*, ?) on the normalized e-mail.",
38
+ "items": {
39
+ "type": "string",
40
+ "minLength": 1
41
+ }
42
+ },
43
+ "oauth_permissions": {
44
+ "type": "object",
45
+ "description": "Map of permission keys to principal emails (or local usernames). Key * grants all permissions.",
46
+ "properties": {
47
+ "*": {
48
+ "type": "array",
49
+ "items": { "type": "string" }
50
+ },
51
+ "pgw.addprojects": {
52
+ "type": "array",
53
+ "items": { "type": "string" }
54
+ },
55
+ "pgw.settings": {
56
+ "type": "array",
57
+ "items": { "type": "string" }
58
+ }
59
+ },
60
+ "additionalProperties": {
61
+ "type": "array",
62
+ "items": { "type": "string" },
63
+ "description": "Scoped keys: pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path)"
64
+ }
65
+ },
66
+ "local_users": {
67
+ "type": "array",
68
+ "uniqueItems": true,
69
+ "minItems": 1,
70
+ "items": {
71
+ "required": [
72
+ "user"
73
+ ],
74
+ "oneOf": [
75
+ { "required": ["pass_hash"] },
76
+ { "required": ["pass"] }
77
+ ],
78
+ "properties": {
79
+ "user": {
80
+ "type": "string",
81
+ "minLength": 2
82
+ },
83
+ "pass": {
84
+ "type": "string",
85
+ "minLength": 8,
86
+ "description": "Plaintext password; hashed to pass_hash on first server start, then removed."
87
+ },
88
+ "pass_hash": {
89
+ "type": "string",
90
+ "minLength": 1,
91
+ "description": "scrypt password hash (written by the server)."
92
+ },
93
+ "email": {
94
+ "type": "string",
95
+ "format": "email",
96
+ "description": "Optional e-mail for Gravatar and permission grants by principal e-mail."
97
+ }
98
+ }
99
+ }
100
+ }
101
+ },
102
+ "required": [
103
+ "auth_mode",
104
+ "salt"
105
+ ]
106
+ }
@@ -0,0 +1,243 @@
1
+ """
2
+ Authentication settings loaded from ~/.pygitweb/auth.json (or PYGITWEB_AUTH_CONFIG).
3
+
4
+ On first run with auth enabled, creates the file with generated local credentials.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import secrets
12
+ import stat
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
18
+
19
+ from pygitweb.config import Settings
20
+ from pygitweb.password_hash import hash_password
21
+ from pygitweb.permissions import PermissionsMap
22
+
23
+ DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
24
+ _AUTH_CONFIG_MODE = 0o600
25
+
26
+
27
+ def _is_windows() -> bool:
28
+ return sys.platform == "win32"
29
+
30
+
31
+ def _auth_config_mode(path: Path) -> int:
32
+ return stat.S_IMODE(path.stat().st_mode)
33
+
34
+
35
+ def _set_auth_config_mode(path: Path) -> None:
36
+ if _is_windows():
37
+ return
38
+ os.chmod(path, _AUTH_CONFIG_MODE)
39
+
40
+
41
+ class AuthConfigPermissionError(RuntimeError):
42
+ """Auth config file exists but is not mode 0600 (non-Windows only)."""
43
+
44
+
45
+ def _require_auth_config_mode(path: Path) -> None:
46
+ if _is_windows():
47
+ return
48
+ mode = _auth_config_mode(path)
49
+ if mode != _AUTH_CONFIG_MODE:
50
+ raise AuthConfigPermissionError(
51
+ f"PyGitWeb: auth config {path} must have mode 0600 (found {mode:#04o}). Run: chmod 600 {path}"
52
+ )
53
+
54
+
55
+ AuthMode = Literal["oauth", "local", "both"]
56
+ OAuthProvider = Literal["google", "github"]
57
+ OAuthUsernameFrom = Literal["sub", "email", "preffered_username", "login", "name"]
58
+
59
+ _AUTH_SALT_HEX_LEN = 16
60
+
61
+
62
+ def generate_auth_salt() -> str:
63
+ return secrets.token_hex(_AUTH_SALT_HEX_LEN // 2)
64
+
65
+
66
+ class LocalUser(BaseModel):
67
+ model_config = ConfigDict(populate_by_name=True)
68
+
69
+ user: str = Field(min_length=2)
70
+ password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
71
+ pass_hash: str = ""
72
+ email: str | None = None
73
+
74
+ @field_validator("email", mode="before")
75
+ @classmethod
76
+ def _normalize_email(cls, value: object) -> str | None:
77
+ if value is None or value == "":
78
+ return None
79
+ if isinstance(value, str):
80
+ stripped = value.strip()
81
+ return stripped or None
82
+ return str(value).strip() or None
83
+
84
+
85
+ class AuthConfig(BaseModel):
86
+ model_config = ConfigDict(populate_by_name=True)
87
+
88
+ salt: str = ""
89
+ auth_mode: AuthMode = "local"
90
+ oauth_provider: OAuthProvider | str = ""
91
+ oauth_client_id: str = ""
92
+ oauth_client_secret: str = ""
93
+ oauth_redirect_uri: str = ""
94
+ oauth_username_from: OAuthUsernameFrom = "email"
95
+ oauth_allowed_emails: list[str] = Field(default_factory=list)
96
+ oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
97
+ local_users: list[LocalUser] = Field(default_factory=list)
98
+
99
+ @field_validator("salt", mode="before")
100
+ @classmethod
101
+ def _validate_salt(cls, value: object) -> str:
102
+ if value is None or value == "":
103
+ return ""
104
+ salt = str(value).strip().lower()
105
+ if len(salt) != _AUTH_SALT_HEX_LEN or not all(c in "0123456789abcdef" for c in salt):
106
+ raise ValueError(f"salt must be a {_AUTH_SALT_HEX_LEN}-character hex string")
107
+ return salt
108
+
109
+ @field_validator("oauth_permissions", mode="before")
110
+ @classmethod
111
+ def _validate_oauth_permissions(cls, v: object) -> PermissionsMap | dict[str, list[str]]:
112
+ if isinstance(v, PermissionsMap):
113
+ return v
114
+ return PermissionsMap.model_validate(v)
115
+
116
+ @field_serializer("oauth_permissions")
117
+ def _serialize_oauth_permissions(self, value: PermissionsMap) -> dict[str, list[str]]:
118
+ return value.root
119
+
120
+
121
+ def auth_config_path(settings: Settings) -> Path:
122
+ if settings.AUTH_CONFIG:
123
+ return Path(settings.AUTH_CONFIG).expanduser()
124
+ return DEFAULT_AUTH_CONFIG_PATH
125
+
126
+
127
+ def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
128
+ return AuthConfig(
129
+ salt=salt,
130
+ auth_mode="local",
131
+ oauth_provider="",
132
+ oauth_client_id="",
133
+ oauth_client_secret="",
134
+ oauth_redirect_uri="",
135
+ oauth_username_from="email",
136
+ oauth_allowed_emails=[],
137
+ oauth_permissions=PermissionsMap.empty(),
138
+ local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
139
+ )
140
+
141
+
142
+ def write_auth_config(path: Path, config: AuthConfig) -> None:
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ path.write_text(
145
+ json.dumps(config.model_dump(by_alias=True, mode="json", exclude_none=True), indent=2) + "\n",
146
+ encoding="utf-8",
147
+ )
148
+ _set_auth_config_mode(path)
149
+
150
+
151
+ def _ensure_auth_salt(path: Path, config: AuthConfig) -> AuthConfig:
152
+ if config.salt:
153
+ return config
154
+ salt = generate_auth_salt()
155
+ updated = config.model_copy(update={"salt": salt})
156
+ write_auth_config(path, updated)
157
+ return updated
158
+
159
+
160
+ def _migrate_plaintext_passwords(path: Path, config: AuthConfig) -> AuthConfig:
161
+ updated_users: list[LocalUser] = []
162
+ changed = False
163
+ for entry in config.local_users:
164
+ if not entry.password:
165
+ updated_users.append(entry)
166
+ continue
167
+ updated_users.append(
168
+ LocalUser(
169
+ user=entry.user,
170
+ pass_hash=hash_password(entry.password, config_salt=config.salt),
171
+ email=entry.email,
172
+ )
173
+ )
174
+ changed = True
175
+ if not changed:
176
+ return config
177
+ migrated = config.model_copy(update={"local_users": updated_users})
178
+ write_auth_config(path, migrated)
179
+ print(
180
+ f"WARNING: hashed plaintext password(s) in {path}.\n",
181
+ file=sys.stderr,
182
+ )
183
+ return migrated
184
+
185
+
186
+ def load_auth_config_file(path: Path) -> AuthConfig:
187
+ return AuthConfig.model_validate_json(path.read_text(encoding="utf-8"))
188
+
189
+
190
+ def is_local_auth_available(config: AuthConfig) -> bool:
191
+ if config.auth_mode not in ("local", "both"):
192
+ return False
193
+ return any(u.pass_hash or u.password for u in config.local_users)
194
+
195
+
196
+ def is_oauth_auth_available(config: AuthConfig) -> bool:
197
+ if config.auth_mode not in ("oauth", "both"):
198
+ return False
199
+ provider = str(config.oauth_provider).strip()
200
+ return bool(
201
+ provider in ("google", "github")
202
+ and config.oauth_client_id.strip()
203
+ and config.oauth_client_secret.strip()
204
+ and config.oauth_redirect_uri.strip()
205
+ )
206
+
207
+
208
+ def is_auth_configured(config: AuthConfig) -> bool:
209
+ if config.auth_mode == "local":
210
+ return is_local_auth_available(config)
211
+ if config.auth_mode == "oauth":
212
+ return is_oauth_auth_available(config)
213
+ return is_local_auth_available(config) or is_oauth_auth_available(config)
214
+
215
+
216
+ def init_auth_config(settings: Settings) -> AuthConfig:
217
+ global auth_config
218
+ path = auth_config_path(settings)
219
+ if path.is_file():
220
+ if settings.AUTH:
221
+ _require_auth_config_mode(path)
222
+ auth_config = _migrate_plaintext_passwords(
223
+ path,
224
+ _ensure_auth_salt(path, load_auth_config_file(path)),
225
+ )
226
+ return auth_config
227
+ if not settings.AUTH:
228
+ auth_config = AuthConfig(auth_mode="local", local_users=[])
229
+ return auth_config
230
+ password = secrets.token_urlsafe(16)
231
+ salt = generate_auth_salt()
232
+ auth_config = default_auth_config(admin_password=password, salt=salt)
233
+ write_auth_config(path, auth_config)
234
+ print(
235
+ f"WARNING: PyGitWeb: created auth config at {path}\n"
236
+ " Set PYGITWEB_AUTH=0 to disable auth, or edit this file for fixed credentials.\n",
237
+ file=sys.stderr,
238
+ )
239
+ print(f" Username: admin\n Password: {password}\n", file=sys.stderr)
240
+ return auth_config
241
+
242
+
243
+ auth_config: AuthConfig = AuthConfig(auth_mode="local", local_users=[])
pygitweb/auth_oauth.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ OAuth2 authorization-code flow (Google / GitHub) for browser login.
3
+
4
+ After the IdP redirects back, PyGitWeb issues the same opaque in-process session id used
5
+ for local login (cookie / Bearer), not an IdP access token. FastAPI's OAuth2* security
6
+ classes only extract credentials from requests; they do not implement this redirect flow.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import fnmatch
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import secrets
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import Annotated, Any
20
+ from urllib.parse import urlencode
21
+
22
+ import httpx
23
+ from fastapi import APIRouter, HTTPException, Query, Request, status
24
+ from fastapi.responses import RedirectResponse
25
+
26
+ from pygitweb.auth_config import AuthConfig, OAuthUsernameFrom, auth_config, is_oauth_auth_available
27
+ from pygitweb.config import settings
28
+ from pygitweb.sessions import create_session
29
+
30
+ OAUTH_STATE_COOKIE_NAME = "pygitweb_oauth_state"
31
+ OAUTH_STATE_MAX_AGE = 600
32
+ ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
33
+ LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
34
+
35
+ TAG_AUTH_OAUTH = "auth - OAuth"
36
+
37
+ oauth_router = APIRouter()
38
+
39
+
40
+ def _safe_next_url(next_raw: str | None) -> str:
41
+ n = (next_raw or "/").strip()
42
+ if not n.startswith("/") or n.startswith("//"):
43
+ return "/"
44
+ return n
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _OAuthProvider:
49
+ authorize_url: str
50
+ token_url: str
51
+ scopes: str
52
+ userinfo_url: str | None
53
+
54
+
55
+ _PROVIDERS: dict[str, _OAuthProvider] = {
56
+ "google": _OAuthProvider(
57
+ authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
58
+ token_url="https://oauth2.googleapis.com/token",
59
+ scopes="openid email profile",
60
+ userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
61
+ ),
62
+ "github": _OAuthProvider(
63
+ authorize_url="https://github.com/login/oauth/authorize",
64
+ token_url="https://github.com/login/oauth/access_token",
65
+ scopes="read:user user:email",
66
+ userinfo_url=None,
67
+ ),
68
+ }
69
+
70
+
71
+ def _provider(config: AuthConfig) -> _OAuthProvider:
72
+ name = str(config.oauth_provider).strip()
73
+ prov = _PROVIDERS.get(name)
74
+ if prov is None:
75
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
76
+ return prov
77
+
78
+
79
+ def _seal_oauth_state(payload: dict[str, Any], secret: str) -> str:
80
+ body = {**payload, "exp": time.time() + OAUTH_STATE_MAX_AGE}
81
+ data = json.dumps(body, separators=(",", ":")).encode()
82
+ sig = hmac.new(secret.encode(), data, hashlib.sha256).digest()
83
+ return base64.urlsafe_b64encode(data + b"." + base64.urlsafe_b64encode(sig)).decode()
84
+
85
+
86
+ def _unseal_oauth_state(token: str, secret: str) -> dict[str, Any]:
87
+ try:
88
+ raw = base64.urlsafe_b64decode(token.encode())
89
+ data, sig_b64 = raw.rsplit(b".", 1)
90
+ sig = base64.urlsafe_b64decode(sig_b64)
91
+ expected = hmac.new(secret.encode(), data, hashlib.sha256).digest()
92
+ if not hmac.compare_digest(sig, expected):
93
+ raise ValueError("invalid signature")
94
+ payload: dict[str, Any] = json.loads(data)
95
+ if float(payload.get("exp", 0)) < time.time():
96
+ raise ValueError("expired")
97
+ return payload
98
+ except (ValueError, json.JSONDecodeError, KeyError) as exc:
99
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
100
+
101
+
102
+ def _pkce_pair() -> tuple[str, str]:
103
+ verifier = secrets.token_urlsafe(48)
104
+ digest = hashlib.sha256(verifier.encode()).digest()
105
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
106
+ return verifier, challenge
107
+
108
+
109
+ def _claim(profile: dict[str, Any], claim: OAuthUsernameFrom) -> str | None:
110
+ if claim == "sub":
111
+ raw = profile.get("sub") or profile.get("id")
112
+ elif claim == "email":
113
+ raw = profile.get("email")
114
+ elif claim == "preffered_username":
115
+ raw = profile.get("preferred_username") or profile.get("preffered_username")
116
+ elif claim == "login":
117
+ raw = profile.get("login")
118
+ elif claim == "name":
119
+ raw = profile.get("name")
120
+ else:
121
+ raw = None
122
+ if raw is None:
123
+ return None
124
+ return str(raw)
125
+
126
+
127
+ def oauth_username_from_profile(config: AuthConfig, profile: dict[str, Any]) -> str:
128
+ username = _claim(profile, config.oauth_username_from)
129
+ if username:
130
+ return username
131
+ for fallback in ("sub", "email", "login", "id"):
132
+ val = profile.get(fallback)
133
+ if val is not None:
134
+ return str(val)
135
+ return "unknown"
136
+
137
+
138
+ def is_oauth_email_allowed(config: AuthConfig, email: str | None) -> bool:
139
+ if not config.oauth_allowed_emails:
140
+ return True
141
+ if not email:
142
+ return False
143
+ normalized = email.strip().lower()
144
+ patterns = [p.strip().lower() for p in config.oauth_allowed_emails if p.strip()]
145
+ return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in patterns)
146
+
147
+
148
+ async def _exchange_code(
149
+ config: AuthConfig,
150
+ *,
151
+ code: str,
152
+ code_verifier: str,
153
+ ) -> dict[str, Any]:
154
+ prov = _provider(config)
155
+ data = {
156
+ "grant_type": "authorization_code",
157
+ "code": code,
158
+ "redirect_uri": config.oauth_redirect_uri,
159
+ "client_id": config.oauth_client_id,
160
+ "client_secret": config.oauth_client_secret,
161
+ "code_verifier": code_verifier,
162
+ }
163
+ headers = {"Accept": "application/json"}
164
+ async with httpx.AsyncClient(timeout=30.0) as client:
165
+ resp = await client.post(prov.token_url, data=data, headers=headers)
166
+ resp.raise_for_status()
167
+ out = resp.json()
168
+ if not isinstance(out, dict):
169
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid token response from provider")
170
+ return out
171
+
172
+
173
+ async def _fetch_google_profile(access_token: str) -> dict[str, Any]:
174
+ prov = _provider(auth_config)
175
+ assert prov.userinfo_url is not None
176
+ headers = {"Authorization": f"Bearer {access_token}"}
177
+ async with httpx.AsyncClient(timeout=30.0) as client:
178
+ resp = await client.get(prov.userinfo_url, headers=headers)
179
+ resp.raise_for_status()
180
+ profile = resp.json()
181
+ if not isinstance(profile, dict):
182
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid userinfo response")
183
+ return profile
184
+
185
+
186
+ async def _fetch_github_profile(access_token: str) -> dict[str, Any]:
187
+ headers = {
188
+ "Authorization": f"Bearer {access_token}",
189
+ "Accept": "application/vnd.github+json",
190
+ }
191
+ async with httpx.AsyncClient(timeout=30.0) as client:
192
+ user_resp = await client.get("https://api.github.com/user", headers=headers)
193
+ user_resp.raise_for_status()
194
+ profile = user_resp.json()
195
+ if not isinstance(profile, dict):
196
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid GitHub user response")
197
+ if profile.get("email"):
198
+ return profile
199
+ email_resp = await client.get("https://api.github.com/user/emails", headers=headers)
200
+ if email_resp.is_success:
201
+ emails = email_resp.json()
202
+ if isinstance(emails, list):
203
+ for entry in emails:
204
+ if isinstance(entry, dict) and entry.get("primary") and entry.get("verified"):
205
+ profile = {**profile, "email": entry.get("email")}
206
+ break
207
+ return profile
208
+
209
+
210
+ async def _fetch_profile(config: AuthConfig, token_payload: dict[str, Any]) -> dict[str, Any]:
211
+ access_token = token_payload.get("access_token")
212
+ if not isinstance(access_token, str) or not access_token:
213
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Provider did not return an access token")
214
+ provider = str(config.oauth_provider).strip()
215
+ if provider == "google":
216
+ return await _fetch_google_profile(access_token)
217
+ if provider == "github":
218
+ return await _fetch_github_profile(access_token)
219
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
220
+
221
+
222
+ def _profile_subject(profile: dict[str, Any]) -> str | None:
223
+ for key in ("sub", "id"):
224
+ val = profile.get(key)
225
+ if val is not None:
226
+ return str(val)
227
+ return None
228
+
229
+
230
+ def _profile_email(profile: dict[str, Any]) -> str | None:
231
+ email = profile.get("email")
232
+ return str(email) if email else None
233
+
234
+
235
+ @oauth_router.get(
236
+ "/auth/oauth/start",
237
+ tags=[TAG_AUTH_OAUTH],
238
+ summary="Start external OAuth sign-in",
239
+ description=(
240
+ "Browser-only. Redirects to the configured provider (Google or GitHub). "
241
+ "On success, /auth/oauth/callback sets the same pygitweb_access_token session cookie as local login."
242
+ ),
243
+ responses={303: {"description": "Redirect to identity provider"}},
244
+ )
245
+ async def oauth_start(
246
+ next: Annotated[str, Query()] = "/",
247
+ ) -> RedirectResponse:
248
+ if not settings.AUTH:
249
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
250
+ if not is_oauth_auth_available(auth_config):
251
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
252
+ prov = _provider(auth_config)
253
+ verifier, challenge = _pkce_pair()
254
+ idp_state = secrets.token_urlsafe(32)
255
+ sealed = _seal_oauth_state(
256
+ {
257
+ "state": idp_state,
258
+ "next": _safe_next_url(next),
259
+ "code_verifier": verifier,
260
+ },
261
+ auth_config.oauth_client_secret,
262
+ )
263
+ params = {
264
+ "response_type": "code",
265
+ "client_id": auth_config.oauth_client_id,
266
+ "redirect_uri": auth_config.oauth_redirect_uri,
267
+ "scope": prov.scopes,
268
+ "state": idp_state,
269
+ "code_challenge": challenge,
270
+ "code_challenge_method": "S256",
271
+ }
272
+ url = f"{prov.authorize_url}?{urlencode(params)}"
273
+ resp = RedirectResponse(url=url, status_code=303)
274
+ resp.set_cookie(
275
+ key=OAUTH_STATE_COOKIE_NAME,
276
+ value=sealed,
277
+ httponly=True,
278
+ samesite="lax",
279
+ max_age=OAUTH_STATE_MAX_AGE,
280
+ path="/",
281
+ )
282
+ return resp
283
+
284
+
285
+ @oauth_router.get(
286
+ "/auth/oauth/callback",
287
+ tags=[TAG_AUTH_OAUTH],
288
+ summary="OAuth provider callback",
289
+ description=(
290
+ "Browser-only. Called by the identity provider after sign-in. "
291
+ "Validates state, exchanges the code, and sets the session cookie. Not intended for Try it out."
292
+ ),
293
+ responses={303: {"description": "Redirect to original next URL with session cookie set"}},
294
+ )
295
+ async def oauth_callback(
296
+ request: Request,
297
+ code: Annotated[str | None, Query()] = None,
298
+ state: Annotated[str | None, Query()] = None,
299
+ error: Annotated[str | None, Query()] = None,
300
+ ) -> RedirectResponse:
301
+ if not settings.AUTH:
302
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
303
+ if not is_oauth_auth_available(auth_config):
304
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
305
+ if error:
306
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"OAuth provider error: {error}")
307
+ if not code or not state:
308
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth code or state")
309
+ sealed = request.cookies.get(OAUTH_STATE_COOKIE_NAME)
310
+ if not sealed:
311
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth state cookie")
312
+ payload = _unseal_oauth_state(sealed, auth_config.oauth_client_secret)
313
+ if not hmac.compare_digest(str(payload.get("state", "")), state):
314
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch")
315
+ code_verifier = payload.get("code_verifier")
316
+ if not isinstance(code_verifier, str) or not code_verifier:
317
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state payload")
318
+ next_url = _safe_next_url(str(payload.get("next") or "/"))
319
+
320
+ token_payload = await _exchange_code(auth_config, code=code, code_verifier=code_verifier)
321
+ profile = await _fetch_profile(auth_config, token_payload)
322
+ email = _profile_email(profile)
323
+ if not is_oauth_email_allowed(auth_config, email):
324
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
325
+ username = oauth_username_from_profile(auth_config, profile)
326
+ subject = _profile_subject(profile)
327
+ sid = create_session(
328
+ username,
329
+ subject=subject,
330
+ email=email,
331
+ auth_method="oauth",
332
+ )
333
+ resp = RedirectResponse(url=next_url, status_code=303)
334
+ resp.set_cookie(
335
+ key=ACCESS_TOKEN_COOKIE_NAME,
336
+ value=sid,
337
+ httponly=True,
338
+ samesite="lax",
339
+ max_age=LOGIN_COOKIE_MAX_AGE,
340
+ path="/",
341
+ )
342
+ resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
343
+ return resp