From 0e8250362efe8d7fcdb8a39f4aed3dd4368efa19 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sat, 9 May 2026 08:48:23 -0400 Subject: [PATCH] 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 --- hbd/server/http.py | 72 ++++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/hbd/server/http.py b/hbd/server/http.py index d6674ce..2a469bd 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -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'' if logo_url else "" - gitea_button = f""" -
or
- - {logo_img}Sign in with Gitea + oauth_buttons = "" + _providers = oauth_mod.get_providers(config) + if _providers: + buttons_html = "" + for _p in _providers: + _logo = f'' if _p.logo else "" + buttons_html += f""" + + {_logo}{_p.label} """ + oauth_buttons = f""" +
or
{buttons_html}""" html = f""" @@ -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; }} @@ -672,7 +675,7 @@ async def start(
- {gitea_button} + {oauth_buttons} """ @@ -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),