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"): elif request.rel_url.query.get("error"):
error = "Sign-in failed. Please try again." error = "Sign-in failed. Please try again."
gitea_button = "" oauth_buttons = ""
if oauth_mod.is_enabled(config): _providers = oauth_mod.get_providers(config)
logo_url = config.get("oauth", {}).get("gitea", {}).get("logo", "") if _providers:
logo_img = f'<img src="{logo_url}" alt="" class="gitea-logo">' if logo_url else "" buttons_html = ""
gitea_button = f""" for _p in _providers:
<div class="divider">or</div> _logo = f'<img src="{_p.logo}" alt="" class="oauth-logo">' if _p.logo else ""
<a href="/login/oauth/gitea" class="gitea-btn"> buttons_html += f"""
{logo_img}Sign in with Gitea <a href="/login/oauth/{_p.name}" class="oauth-btn">
{_logo}{_p.label}
</a>""" </a>"""
oauth_buttons = f"""
<div class="divider">or</div>{buttons_html}"""
html = f"""<!DOCTYPE html> html = f"""<!DOCTYPE html>
<html> <html>
@@ -656,12 +659,12 @@ async def start(
.field {{ margin-bottom: .9em; }} .field {{ margin-bottom: .9em; }}
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999; .divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }} 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; gap: .5em; width: 100%; padding: .6em; background: #16191d;
color: #fff; border-radius: 4px; font-size: 1em; text-align: center; color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
text-decoration: none; box-sizing: border-box; }} text-decoration: none; box-sizing: border-box; margin-top: .5em; }}
.gitea-btn:hover {{ background: #4e7d1e; }} .oauth-btn:hover {{ background: #444; }}
.gitea-logo {{ height: 1.2em; width: auto; vertical-align: middle; }} .oauth-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
</style> </style>
</head> </head>
<body> <body>
@@ -672,7 +675,7 @@ async def start(
<div class="field"><label>Username</label><input name="username" autofocus></div> <div class="field"><label>Username</label><input name="username" autofocus></div>
<div class="field"><label>Password</label><input name="password" type="password"></div> <div class="field"><label>Password</label><input name="password" type="password"></div>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form>{gitea_button} </form>{oauth_buttons}
</div> </div>
</body> </body>
</html>""" </html>"""
@@ -918,21 +921,34 @@ async def start(
) )
return web.Response(text=body, content_type="text/html") 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()) 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): def _get_oauth_provider(name: str):
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow.""" """Return the ResolvedProvider for *name*, or None if not found."""
if not oauth_mod.is_enabled(config): return next(
return web.Response(status=404, text="OAuth not configured") (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() 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): async def oauth_callback(request):
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back.""" """GET /login/oauth/{name}/callback — handle the provider's redirect back."""
if not oauth_mod.is_enabled(config): name = request.match_info["name"]
return web.Response(status=404, text="OAuth not configured") 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", "") code = request.rel_url.query.get("code", "")
state = request.rel_url.query.get("state", "") state = request.rel_url.query.get("state", "")
if not code or not 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) logger.warning("OAuth: invalid or expired state token from %s", request.remote)
raise web.HTTPFound("/login?error=1") raise web.HTTPFound("/login?error=1")
try: try:
token = await oauth_mod.exchange_code(config, code, _oauth_redirect_uri(request)) token = await oauth_mod.exchange_code(provider, code, _oauth_redirect_uri(request, name))
profile = await oauth_mod.fetch_user(config, token) profile = await oauth_mod.fetch_user(provider, token)
except oauth_mod.OAuthError as exc: except oauth_mod.OAuthError as exc:
logger.warning("OAuth error: %s", exc) logger.warning("OAuth error: %s", exc)
raise web.HTTPFound("/login?error=1") raise web.HTTPFound("/login?error=1")
@@ -973,8 +989,8 @@ async def start(
web.get("/logout", web_logout), web.get("/logout", web_logout),
web.post("/api/0/auth/login", api_login), web.post("/api/0/auth/login", api_login),
web.post("/api/0/auth/logout", api_logout), web.post("/api/0/auth/logout", api_logout),
web.get("/login/oauth/gitea", oauth_gitea_redirect), web.get("/login/oauth/{name}", oauth_redirect),
web.get("/login/oauth/gitea/callback", oauth_gitea_callback), web.get("/login/oauth/{name}/callback", oauth_callback),
# Users # Users
web.get("/api/0/users", api_users), web.get("/api/0/users", api_users),
web.get("/api/0/users/me", api_user_self), web.get("/api/0/users/me", api_user_self),