Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions src/Orbit.Api/Controllers/OAuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public partial class OAuthController(
ILogger<OAuthController> 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"];
Expand Down Expand Up @@ -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" });
Expand All @@ -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");
}
Expand All @@ -150,7 +155,8 @@ public async Task<IActionResult> 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")]
Expand All @@ -159,6 +165,9 @@ public async Task<IActionResult> 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);

Expand All @@ -167,7 +176,7 @@ public async Task<IActionResult> 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)}";
Expand All @@ -177,7 +186,8 @@ public async Task<IActionResult> 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")]
Expand All @@ -186,6 +196,9 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -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)}";
Expand Down Expand Up @@ -312,12 +325,16 @@ public async Task<IActionResult> Token(
if (logger.IsEnabled(LogLevel.Information))
LogOAuthApiKeyCreated(logger, entry.UserId, entry.ClientId);

return Ok(new
var response = new Dictionary<string, object>
{
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)
Expand Down
5 changes: 3 additions & 2 deletions src/Orbit.Api/OAuth/OAuthAuthorizationStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ public OAuthAuthorizationStore(ILogger<OAuthAuthorizationStore> 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;
}
Expand Down Expand Up @@ -72,4 +72,5 @@ public record AuthorizationEntry(
string CodeChallenge,
string RedirectUri,
string ClientId,
string? Nonce,
DateTime CreatedAt);
36 changes: 18 additions & 18 deletions src/Orbit.Api/OAuth/OAuthLoginPage.cs
Original file line number Diff line number Diff line change
@@ -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 $$"""
<!DOCTYPE html>
Expand Down Expand Up @@ -283,14 +287,8 @@ Continue with Google
</div>

<script>
const oauthParams = {
clientId: '{{clientId}}',
redirectUri: '{{redirectUri}}',
state: '{{state}}',
codeChallenge: '{{codeChallenge}}',
codeChallengeMethod: '{{codeChallengeMethod}}'
};
const googleClientId = '{{googleClientId}}';
const oauthParams = {{oauthParamsJson}};
const googleClientId = {{googleClientIdJson}};
let userEmail = '';
let resendTimer = null;

Expand Down Expand Up @@ -415,7 +413,8 @@ async function verifyCode() {
state: oauthParams.state,
codeChallenge: oauthParams.codeChallenge,
redirectUri: oauthParams.redirectUri,
clientId: oauthParams.clientId
clientId: oauthParams.clientId,
nonce: oauthParams.nonce
})
});
const data = await res.json();
Expand Down Expand Up @@ -458,7 +457,8 @@ async function handleGoogleCredential(response) {
state: oauthParams.state,
codeChallenge: oauthParams.codeChallenge,
redirectUri: oauthParams.redirectUri,
clientId: oauthParams.clientId
clientId: oauthParams.clientId,
nonce: oauthParams.nonce
})
});
const data = await res.json();
Expand Down
7 changes: 7 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8635,6 +8635,13 @@
"schema": {
"type": "string"
}
},
{
"name": "nonce",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
Expand Down
Loading
Loading