diff --git a/src/Orbit.Api/Controllers/OAuthController.cs b/src/Orbit.Api/Controllers/OAuthController.cs index 0985ba7f..202d2d48 100644 --- a/src/Orbit.Api/Controllers/OAuthController.cs +++ b/src/Orbit.Api/Controllers/OAuthController.cs @@ -34,6 +34,7 @@ public partial class OAuthController( ILogger logger) : ControllerBase { private const string InvalidRedirectUriError = "invalid_redirect_uri"; + private const string MissingStateError = "invalid_request"; private static readonly string[] SupportedResponseTypes = ["code"]; private static readonly string[] SupportedGrantTypes = ["authorization_code"]; private static readonly string[] SupportedCodeChallengeMethods = ["S256"]; @@ -116,7 +117,8 @@ public IActionResult Authorize( [FromQuery] string response_type, [FromQuery] string state, [FromQuery] string code_challenge, - [FromQuery] string code_challenge_method) + [FromQuery] string code_challenge_method, + [FromQuery] string? nonce = null) { if (response_type != "code") return BadRequest(new { error = "unsupported_response_type" }); @@ -127,10 +129,13 @@ public IActionResult Authorize( if (!IsRedirectUriAllowed(redirect_uri)) return BadRequest(new { error = InvalidRedirectUriError }); + if (string.IsNullOrEmpty(state)) + return BadRequest(new { error = MissingStateError, error_description = "state is required for CSRF protection" }); + var googleClientId = googleSettings.Value.ClientId ?? ""; var html = OAuthLoginPage.Render( client_id, redirect_uri, state, - code_challenge, code_challenge_method, googleClientId); + code_challenge, code_challenge_method, googleClientId, nonce); return Content(html, "text/html"); } @@ -150,7 +155,8 @@ public async Task SendCode([FromBody] SendCodeRequest request, Ca public record VerifyCodeRequest( string Email, string Code, - string State, string CodeChallenge, string RedirectUri, string ClientId); + string State, string CodeChallenge, string RedirectUri, string ClientId, + string? Nonce = null); [HttpPost("/oauth/verify-code")] [DistributedRateLimit("auth")] @@ -159,6 +165,9 @@ public async Task VerifyCode([FromBody] VerifyCodeRequest request if (!IsRedirectUriAllowed(request.RedirectUri)) return BadRequest(new { error = InvalidRedirectUriError }); + if (string.IsNullOrEmpty(request.State)) + return BadRequest(new { error = MissingStateError }); + var result = await mediator.Send( new VerifyCodeCommand(request.Email, request.Code), ct); @@ -167,7 +176,7 @@ public async Task VerifyCode([FromBody] VerifyCodeRequest request var loginResponse = result.Value; var authCode = authStore.CreateCode( - loginResponse.UserId, request.CodeChallenge, request.RedirectUri, request.ClientId); + loginResponse.UserId, request.CodeChallenge, request.RedirectUri, request.ClientId, request.Nonce); var separator = request.RedirectUri.Contains('?') ? "&" : "?"; var redirectUrl = $"{request.RedirectUri}{separator}code={Uri.EscapeDataString(authCode)}&state={Uri.EscapeDataString(request.State)}"; @@ -177,7 +186,8 @@ public async Task VerifyCode([FromBody] VerifyCodeRequest request public record GoogleAuthRequest( string Credential, - string State, string CodeChallenge, string RedirectUri, string ClientId); + string State, string CodeChallenge, string RedirectUri, string ClientId, + string? Nonce = null); [HttpPost("/oauth/google")] [DistributedRateLimit("auth")] @@ -186,6 +196,9 @@ public async Task GoogleAuth([FromBody] GoogleAuthRequest request if (!IsRedirectUriAllowed(request.RedirectUri)) return BadRequest(new { error = InvalidRedirectUriError }); + if (string.IsNullOrEmpty(request.State)) + return BadRequest(new { error = MissingStateError }); + var client = httpClientFactory.CreateClient(); var response = await client.GetAsync( $"https://oauth2.googleapis.com/tokeninfo?id_token={Uri.EscapeDataString(request.Credential)}", ct); @@ -230,7 +243,7 @@ await ConcurrencyRetry.SaveWithRetryAsync( } var authCode = authStore.CreateCode( - user.Id, request.CodeChallenge, request.RedirectUri, request.ClientId); + user.Id, request.CodeChallenge, request.RedirectUri, request.ClientId, request.Nonce); var separator = request.RedirectUri.Contains('?') ? "&" : "?"; var redirectUrl = $"{request.RedirectUri}{separator}code={Uri.EscapeDataString(authCode)}&state={Uri.EscapeDataString(request.State)}"; @@ -312,12 +325,16 @@ public async Task Token( if (logger.IsEnabled(LogLevel.Information)) LogOAuthApiKeyCreated(logger, entry.UserId, entry.ClientId); - return Ok(new + var response = new Dictionary { - access_token = rawKey, - token_type = "Bearer", - scope = string.Join(' ', AgentScopes.ClaudeDefaultScopes) - }); + ["access_token"] = rawKey, + ["token_type"] = "Bearer", + ["scope"] = string.Join(' ', AgentScopes.ClaudeDefaultScopes) + }; + if (!string.IsNullOrEmpty(entry.Nonce)) + response["nonce"] = entry.Nonce; + + return Ok(response); } private bool IsRedirectUriAllowed(string redirectUri) diff --git a/src/Orbit.Api/OAuth/OAuthAuthorizationStore.cs b/src/Orbit.Api/OAuth/OAuthAuthorizationStore.cs index 1de3323e..d52d2419 100644 --- a/src/Orbit.Api/OAuth/OAuthAuthorizationStore.cs +++ b/src/Orbit.Api/OAuth/OAuthAuthorizationStore.cs @@ -20,12 +20,12 @@ public OAuthAuthorizationStore(ILogger logger) }, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); } - public string CreateCode(Guid userId, string codeChallenge, string redirectUri, string clientId) + public string CreateCode(Guid userId, string codeChallenge, string redirectUri, string clientId, string? nonce = null) { var code = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) .Replace("+", "-").Replace("/", "_").TrimEnd('='); - var entry = new AuthorizationEntry(userId, codeChallenge, redirectUri, clientId, DateTime.UtcNow); + var entry = new AuthorizationEntry(userId, codeChallenge, redirectUri, clientId, nonce, DateTime.UtcNow); _codes[code] = entry; return code; } @@ -72,4 +72,5 @@ public record AuthorizationEntry( string CodeChallenge, string RedirectUri, string ClientId, + string? Nonce, DateTime CreatedAt); diff --git a/src/Orbit.Api/OAuth/OAuthLoginPage.cs b/src/Orbit.Api/OAuth/OAuthLoginPage.cs index f1e67ee9..903c69ca 100644 --- a/src/Orbit.Api/OAuth/OAuthLoginPage.cs +++ b/src/Orbit.Api/OAuth/OAuthLoginPage.cs @@ -1,18 +1,22 @@ -using System.Net; +using System.Text.Json; namespace Orbit.Api.OAuth; public static class OAuthLoginPage { public static string Render(string clientId, string redirectUri, string state, - string codeChallenge, string codeChallengeMethod, string googleClientId) + string codeChallenge, string codeChallengeMethod, string googleClientId, string? nonce = null) { - clientId = WebUtility.HtmlEncode(clientId); - redirectUri = WebUtility.HtmlEncode(redirectUri); - state = WebUtility.HtmlEncode(state); - codeChallenge = WebUtility.HtmlEncode(codeChallenge); - codeChallengeMethod = WebUtility.HtmlEncode(codeChallengeMethod); - googleClientId = WebUtility.HtmlEncode(googleClientId); + var oauthParamsJson = JsonSerializer.Serialize(new + { + clientId, + redirectUri, + state, + codeChallenge, + codeChallengeMethod, + nonce + }); + var googleClientIdJson = JsonSerializer.Serialize(googleClientId); return $$""" @@ -283,14 +287,8 @@ Continue with Google