| 1 | using AgentForge; |
| 2 | using AgentForge.Abstractions; |
| 3 | using AgentForge.Data.Entities; |
| 4 | using AgentForge.Plugin.OAuth.Web.Pages; |
| 5 | using AgentForge.Plugin.View; |
| 6 | using AgentForge.Services; |
| 7 | using Microsoft.AspNetCore.Http; |
| 8 | |
| 9 | namespace AgentForge.Plugin.OAuth.Web; |
| 10 | |
| 11 | public static class ForgeAuthViewEndpoints |
| 12 | { |
| 13 | public static WebApplication Map(WebApplication app) |
| 14 | { |
| 15 | app.MapGet("/view/login", (ForgeOAuthProviderRegistry providers, IForgeOAuthLoginContributorCatalog oauthUi, HttpContext http, string? error) => |
| 16 | RenderLoginView(providers, oauthUi, http, error)); |
| 17 | app.MapGet("/view/auth/oauth/start", StartViewOAuth); |
| 18 | app.MapGet("/view/auth/oauth/callback", CompleteViewOAuth); |
| 19 | app.MapPost("/view/auth/session", SetViewSession); |
| 20 | app.MapPost("/view/auth/logout", ClearViewSession); |
| 21 | app.MapGet("/view/auth/device", RenderDeviceLoginView); |
| 22 | return app; |
| 23 | } |
| 24 | |
| 25 | private static IResult SetViewSession(HttpContext http, ForgeAuthService auth) |
| 26 | { |
| 27 | var token = http.Request.Form["token"].ToString().Trim(); |
| 28 | if (token.Length == 0) |
| 29 | token = http.Request.Query["token"].ToString().Trim(); |
| 30 | |
| 31 | if (token.Length == 0) |
| 32 | return Results.Redirect("/view/login?error=missing_token"); |
| 33 | |
| 34 | if (auth.ValidateApiToken(token) is null) |
| 35 | return Results.Redirect("/view/login?error=invalid_token"); |
| 36 | |
| 37 | ForgeViewSession.SetSessionCookie(http.Response, token); |
| 38 | var returnUrl = http.Request.Query["return"].ToString(); |
| 39 | if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith("/view", StringComparison.Ordinal)) |
| 40 | returnUrl = "/view"; |
| 41 | return Results.Redirect(returnUrl); |
| 42 | } |
| 43 | |
| 44 | private static IResult ClearViewSession(HttpContext http) |
| 45 | { |
| 46 | ForgeViewSession.ClearSessionCookie(http.Response); |
| 47 | return Results.Redirect("/view/login"); |
| 48 | } |
| 49 | |
| 50 | private static IResult StartViewOAuth( |
| 51 | string? provider, |
| 52 | string? returnUrl, |
| 53 | ForgeGitHubOAuthService github, |
| 54 | ForgeUrls urls, |
| 55 | HttpContext http) |
| 56 | { |
| 57 | if (!string.Equals(provider ?? "github", "github", StringComparison.OrdinalIgnoreCase) || !github.IsConfigured) |
| 58 | return Results.Redirect("/view/login?error=oauth_not_configured"); |
| 59 | |
| 60 | var callback = urls.ViewOAuthCallback(); |
| 61 | if (!ForgeOAuthRedirectAllowlist.IsAllowed(callback, urls.PublicBaseUrl)) |
| 62 | return Results.Problem("OAuth callback URL is not allowed for this forge host.", statusCode: StatusCodes.Status503ServiceUnavailable); |
| 63 | |
| 64 | var state = github.CreateState(callback, codeChallenge: null, codeChallengeMethod: null, "read,write,accept_merge"); |
| 65 | http.Response.Cookies.Append( |
| 66 | ForgeOAuthViewConstants.ReturnCookieName, |
| 67 | ForgeOAuthViewConstants.SanitizeReturnUrl(returnUrl), |
| 68 | new CookieOptions |
| 69 | { |
| 70 | HttpOnly = true, |
| 71 | SameSite = SameSiteMode.Lax, |
| 72 | Path = "/", |
| 73 | MaxAge = TimeSpan.FromMinutes(15), |
| 74 | IsEssential = true, |
| 75 | }); |
| 76 | |
| 77 | return Results.Redirect(github.BuildAuthorizeUrl(state)); |
| 78 | } |
| 79 | |
| 80 | private static IResult CompleteViewOAuth( |
| 81 | string? code, |
| 82 | string? state, |
| 83 | ForgeOAuthLoginService oauthLogin, |
| 84 | HttpContext http) |
| 85 | { |
| 86 | if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) |
| 87 | return Results.Redirect("/view/login?error=oauth_failed"); |
| 88 | |
| 89 | try |
| 90 | { |
| 91 | var issued = oauthLogin.ExchangeAuthorizationCodeForView(code, state, http.RequestAborted); |
| 92 | ForgeViewSession.SetSessionCookie(http.Response, issued.AccessToken); |
| 93 | } |
| 94 | catch (ForgeApiException ex) when (ex.StatusCode == 403) |
| 95 | { |
| 96 | return Results.Redirect("/view/login?error=access_denied"); |
| 97 | } |
| 98 | catch (ForgeApiException) |
| 99 | { |
| 100 | return Results.Redirect("/view/login?error=oauth_failed"); |
| 101 | } |
| 102 | |
| 103 | var returnUrl = "/view"; |
| 104 | if (http.Request.Cookies.TryGetValue(ForgeOAuthViewConstants.ReturnCookieName, out var stored)) |
| 105 | returnUrl = ForgeOAuthViewConstants.SanitizeReturnUrl(stored); |
| 106 | |
| 107 | http.Response.Cookies.Delete(ForgeOAuthViewConstants.ReturnCookieName, new CookieOptions { Path = "/" }); |
| 108 | return Results.Redirect(returnUrl); |
| 109 | } |
| 110 | |
| 111 | private static IResult RenderLoginView( |
| 112 | ForgeOAuthProviderRegistry providers, |
| 113 | IForgeOAuthLoginContributorCatalog oauthUi, |
| 114 | HttpContext http, |
| 115 | string? error) |
| 116 | { |
| 117 | var viewer = ForgeViewPresenter.ResolveViewer(http); |
| 118 | return ForgeViewPresenter.Page("Sign in", ForgeAuthPages.RenderLogin(providers, oauthUi, viewer, error), http); |
| 119 | } |
| 120 | |
| 121 | private static IResult RenderDeviceLoginView(string? code, ForgeDeviceAuthService deviceAuth, HttpContext http) |
| 122 | { |
| 123 | var userCode = NormalizeDeviceUserCode(code); |
| 124 | var pending = userCode.Length == 0 ? null : deviceAuth.GetPendingByUserCode(userCode); |
| 125 | return ForgeViewPresenter.Page("Device login", ForgeAuthPages.RenderDeviceLogin(userCode, pending), http); |
| 126 | } |
| 127 | |
| 128 | private static string NormalizeDeviceUserCode(string? code) => |
| 129 | (code ?? "").Trim().ToUpperInvariant().Replace(" ", ""); |
| 130 | } |
| 131 | |