feat: multi-provider OAuth2 login page and generic routes

Replace hardcoded Gitea OAuth handlers with generic {name}-parameterized
routes and update the login page to render a button for each configured
provider via oauth_mod.get_providers().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 08:48:23 -04:00
parent 2f5da9fc5e
commit 0e8250362e
+44 -28
View File
@@ -625,15 +625,18 @@ async def start(
elif request.rel_url.query.get("error"):
error = "Sign-in failed. Please try again."
gitea_button = ""
if oauth_mod.is_enabled(config):
logo_url = config.get("oauth", {}).get("gitea", {}).get("logo", "")
logo_img = f'<img src="{logo_url}" alt="" class="gitea-logo">' if logo_url else ""
gitea_button = f"""
<div class="divider">or</div>
<a href="/login/oauth/gitea" class="gitea-btn">
{logo_img}Sign in with Gitea
oauth_buttons = ""
_providers = oauth_mod.get_providers(config)
if _providers:
buttons_html = ""
for _p in _providers:
_logo = f'<img src="{_p.logo}" alt="" class="oauth-logo">' if _p.logo else ""
buttons_html += f"""
<a href="/login/oauth/{_p.name}" class="oauth-btn">
{_logo}{_p.label}
</a>"""
oauth_buttons = f"""
<div class="divider">or</div>{buttons_html}"""
html = f"""<!DOCTYPE html>
<html>
@@ -656,12 +659,12 @@ async def start(
.field {{ margin-bottom: .9em; }}
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
.gitea-btn {{ display: flex; align-items: center; justify-content: center;
.oauth-btn {{ display: flex; align-items: center; justify-content: center;
gap: .5em; width: 100%; padding: .6em; background: #16191d;
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
text-decoration: none; box-sizing: border-box; }}
.gitea-btn:hover {{ background: #4e7d1e; }}
.gitea-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
text-decoration: none; box-sizing: border-box; margin-top: .5em; }}
.oauth-btn:hover {{ background: #444; }}
.oauth-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
</style>
</head>
<body>
@@ -672,7 +675,7 @@ async def start(
<div class="field"><label>Username</label><input name="username" autofocus></div>
<div class="field"><label>Password</label><input name="password" type="password"></div>
<button type="submit">Sign in</button>
</form>{gitea_button}
</form>{oauth_buttons}
</div>
</body>
</html>"""
@@ -918,21 +921,34 @@ async def start(
)
return web.Response(text=body, content_type="text/html")
def _oauth_redirect_uri(request) -> str:
def _oauth_redirect_uri(request, provider_name: str) -> str:
base = config.get("base_url", "").rstrip("/") or str(request.url.origin())
return f"{base}/login/oauth/gitea/callback"
return f"{base}/login/oauth/{provider_name}/callback"
async def oauth_gitea_redirect(request):
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
if not oauth_mod.is_enabled(config):
return web.Response(status=404, text="OAuth not configured")
def _get_oauth_provider(name: str):
"""Return the ResolvedProvider for *name*, or None if not found."""
return next(
(p for p in oauth_mod.get_providers(config) if p.name == name),
None,
)
async def oauth_redirect(request):
"""GET /login/oauth/{name} — kick off the OAuth2 flow for the named provider."""
name = request.match_info["name"]
provider = _get_oauth_provider(name)
if provider is None:
return web.Response(status=404, text="OAuth provider not found")
state = oauth_mod.make_state()
raise web.HTTPFound(oauth_mod.authorization_url(config, state, _oauth_redirect_uri(request)))
raise web.HTTPFound(
oauth_mod.build_auth_url(provider, state, _oauth_redirect_uri(request, name))
)
async def oauth_gitea_callback(request):
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
if not oauth_mod.is_enabled(config):
return web.Response(status=404, text="OAuth not configured")
async def oauth_callback(request):
"""GET /login/oauth/{name}/callback — handle the provider's redirect back."""
name = request.match_info["name"]
provider = _get_oauth_provider(name)
if provider is None:
return web.Response(status=404, text="OAuth provider not found")
code = request.rel_url.query.get("code", "")
state = request.rel_url.query.get("state", "")
if not code or not state:
@@ -941,8 +957,8 @@ async def start(
logger.warning("OAuth: invalid or expired state token from %s", request.remote)
raise web.HTTPFound("/login?error=1")
try:
token = await oauth_mod.exchange_code(config, code, _oauth_redirect_uri(request))
profile = await oauth_mod.fetch_user(config, token)
token = await oauth_mod.exchange_code(provider, code, _oauth_redirect_uri(request, name))
profile = await oauth_mod.fetch_user(provider, token)
except oauth_mod.OAuthError as exc:
logger.warning("OAuth error: %s", exc)
raise web.HTTPFound("/login?error=1")
@@ -973,8 +989,8 @@ async def start(
web.get("/logout", web_logout),
web.post("/api/0/auth/login", api_login),
web.post("/api/0/auth/logout", api_logout),
web.get("/login/oauth/gitea", oauth_gitea_redirect),
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
web.get("/login/oauth/{name}", oauth_redirect),
web.get("/login/oauth/{name}/callback", oauth_callback),
# Users
web.get("/api/0/users", api_users),
web.get("/api/0/users/me", api_user_self),