From f8331018e7af8de0879b5c1f0ed5e567484df7ef Mon Sep 17 00:00:00 2001 From: Cypher <223556219+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:40:20 -0700 Subject: [PATCH] feat(auth): add Repo App user authorization Closes #941 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7762866f-fa88-4087-b5df-17f482d36ec0 --- .changeset/repo-app-user-authorization.md | 5 + .../Auth/HumanEntraSubjectAuthorization.cs | 20 + .../Auth/RepoAppUserAuthorizationService.cs | 706 ++++++++++++++++++ .../Auth/TwoAppPersistenceStore.cs | 269 ++++++- .../Endpoints/AuthEndpoints.cs | 113 +++ docs/guide/configuration.md | 24 + docs/reference/api.md | 5 + .../RepoAppUserAuthorizationServiceTests.cs | 390 ++++++++++ 8 files changed, 1528 insertions(+), 4 deletions(-) create mode 100644 .changeset/repo-app-user-authorization.md create mode 100644 apps/Agentweaver.Api/Auth/HumanEntraSubjectAuthorization.cs create mode 100644 apps/Agentweaver.Api/Auth/RepoAppUserAuthorizationService.cs create mode 100644 tests/Agentweaver.Tests/Auth/RepoAppUserAuthorizationServiceTests.cs diff --git a/.changeset/repo-app-user-authorization.md b/.changeset/repo-app-user-authorization.md new file mode 100644 index 000000000..a08753d43 --- /dev/null +++ b/.changeset/repo-app-user-authorization.md @@ -0,0 +1,5 @@ +--- +"agentweaver": minor +--- + +Add explicit, Entra-user-bound GitHub Repo App authorization with PKCE, safe callback handling, refresh, and revocation. diff --git a/apps/Agentweaver.Api/Auth/HumanEntraSubjectAuthorization.cs b/apps/Agentweaver.Api/Auth/HumanEntraSubjectAuthorization.cs new file mode 100644 index 000000000..7d208ee9c --- /dev/null +++ b/apps/Agentweaver.Api/Auth/HumanEntraSubjectAuthorization.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Agentweaver.Api.Security; + +namespace Agentweaver.Api.Auth; + +public enum HumanEntraSubjectState +{ + Allowed, + HumanEntraSubjectRequired, +} + +/// Single fail-closed predicate for GitHub credential mutation. +public static class HumanEntraSubjectAuthorization +{ + public static HumanEntraSubjectState Evaluate(CallerContext caller, ClaimsPrincipal principal) => + !string.IsNullOrWhiteSpace(caller.EntraObjectId) && + !principal.HasClaim("agentweaver_internal", "true") + ? HumanEntraSubjectState.Allowed + : HumanEntraSubjectState.HumanEntraSubjectRequired; +} diff --git a/apps/Agentweaver.Api/Auth/RepoAppUserAuthorizationService.cs b/apps/Agentweaver.Api/Auth/RepoAppUserAuthorizationService.cs new file mode 100644 index 000000000..7e4a6c7f8 --- /dev/null +++ b/apps/Agentweaver.Api/Auth/RepoAppUserAuthorizationService.cs @@ -0,0 +1,706 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Agentweaver.Api.Memory; +using Agentweaver.Api.Security; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Npgsql; + +namespace Agentweaver.Api.Auth; + +public enum RepoAppAuthorizationOutcome +{ + Success, + HumanEntraSubjectRequired, + AuthorizationTransactionInvalid, + AuthorizationTransactionConsumed, + GitHubBindingUnavailable, + RateLimited, +} + +public sealed record RepoAppAuthorizationBeginResult( + RepoAppAuthorizationOutcome Outcome, + string? AuthorizationUrl, + string? TransactionId, + DateTimeOffset? ExpiresAt) +{ + [JsonIgnore] + public string? CallbackCookie { get; init; } +} + +public sealed record RepoAppAuthorizationCallbackResult( + RepoAppAuthorizationOutcome Outcome, + string ReturnRouteKey); + +public sealed record RepoAppAuthorizationPollResult( + RepoAppAuthorizationOutcome Outcome, + string? Status); + +/// +/// Repo App's explicit Entra-user authorization lane. It has no dependency on legacy +/// GitHub token stores, so its Key Vault credential tombstones cannot resurrect disk state. +/// +public sealed class RepoAppUserAuthorizationService( + IConfiguration configuration, + TwoAppPersistenceStore persistence, + ISecretStore secretStore, + IHttpClientFactory httpClientFactory) +{ + private const string CookieName = "__Host-agentweaver-repo-app-auth"; + private const string CredentialStatusSignedIn = "signed-in"; + private const string CredentialStatusRevoked = "revoked"; + private static readonly TimeSpan TransactionLifetime = TimeSpan.FromMinutes(10); + private static readonly TimeSpan ProviderTimeout = TimeSpan.FromSeconds(10); + private static readonly ConcurrentDictionary RateWindows = new(StringComparer.Ordinal); + private static readonly IReadOnlyDictionary ReturnRoutes = + new Dictionary(StringComparer.Ordinal) + { + ["settings"] = "/settings", + ["projects"] = "/projects", + }; + + private readonly string _baseUrl = configuration["Auth:RepoApp:BaseUrl"] ?? "https://github.com"; + private readonly string? _clientId = configuration["Auth:RepoApp:ClientId"]; + private readonly string? _clientSecret = configuration["Auth:RepoApp:ClientSecret"]; + private readonly string? _callbackUrl = configuration["Auth:RepoApp:CallbackUrl"]; + private readonly string _scopes = configuration["Auth:RepoApp:Scopes"] ?? "repo read:user"; + + public static string CallbackCookieName => CookieName; + + public async Task BeginAsync( + CallerContext caller, + ClaimsPrincipal principal, + string? requestedReturnRouteKey, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return new(RepoAppAuthorizationOutcome.HumanEntraSubjectRequired, null, null, null); + if (!TryConsumeRateLimit(caller.EntraObjectId!)) + return new(RepoAppAuthorizationOutcome.RateLimited, null, null, null); + if (!ReturnRoutes.ContainsKey(requestedReturnRouteKey ?? "settings") || + string.IsNullOrWhiteSpace(_clientId) || + string.IsNullOrWhiteSpace(_clientSecret) || + string.IsNullOrWhiteSpace(_callbackUrl)) + return new(RepoAppAuthorizationOutcome.GitHubBindingUnavailable, null, null, null); + + var state = CreateRandomValue(); + var transactionId = TwoAppPersistenceStore.CreateExternalTransactionId(); + var callbackCookie = CreateRandomValue(); + var verifier = CreateRandomValue(); + var now = DateTimeOffset.UtcNow; + var expiresAt = now.Add(TransactionLifetime); + var verifierReference = $"repo-app-pkce-{transactionId}"; + await secretStore.SetSecretAsync(verifierReference, verifier, ct: ct).ConfigureAwait(false); + + try + { + await persistence.AddAuthorizationAsync(new GitHubAuthorizationRecord + { + State = state, + ExternalTransactionId = transactionId, + AppKind = GitHubAppKind.Repo, + Purpose = GitHubAuthorizationPurpose.InteractiveRepository, + EntraObjectId = caller.EntraObjectId!, + ExpiresAtUnixMilliseconds = expiresAt.ToUnixTimeMilliseconds(), + ReturnRouteKey = requestedReturnRouteKey ?? "settings", + PkceVerifierProtected = verifierReference, + CallbackCookieHash = HashCookie(callbackCookie), + Status = GitHubAuthorizationStatus.Pending, + CreatedAt = now, + }, ct).ConfigureAwait(false); + } + catch + { + await WriteTombstoneAsync(verifierReference, ct).ConfigureAwait(false); + return new(RepoAppAuthorizationOutcome.GitHubBindingUnavailable, null, null, null); + } + + var authorizeUrl = $"{_baseUrl.TrimEnd('/')}/login/oauth/authorize" + + $"?client_id={Uri.EscapeDataString(_clientId!)}" + + $"&redirect_uri={Uri.EscapeDataString(_callbackUrl!)}" + + $"&scope={Uri.EscapeDataString(_scopes)}" + + $"&state={Uri.EscapeDataString(state)}" + + $"&code_challenge={Uri.EscapeDataString(CreateS256Challenge(verifier))}" + + "&code_challenge_method=S256"; + return new(RepoAppAuthorizationOutcome.Success, authorizeUrl, transactionId, expiresAt) + { + CallbackCookie = callbackCookie, + }; + } + + public async Task CompleteAsync( + CallerContext caller, + ClaimsPrincipal principal, + string? state, + string? code, + string? callbackCookie, + CancellationToken ct = default) + { + const string defaultRoute = "settings"; + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return new(RepoAppAuthorizationOutcome.HumanEntraSubjectRequired, defaultRoute); + if (string.IsNullOrWhiteSpace(state)) + return new(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, defaultRoute); + + var transaction = await persistence.GetRepoAppAuthorizationTransactionAsync(state, ct).ConfigureAwait(false); + if (transaction is null || + transaction.AppKind != GitHubAppKind.Repo || + transaction.Purpose != GitHubAuthorizationPurpose.InteractiveRepository || + !string.Equals(transaction.EntraObjectId, caller.EntraObjectId, StringComparison.Ordinal) || + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > transaction.ExpiresAtUnixMilliseconds || + string.IsNullOrWhiteSpace(callbackCookie) || + !FixedTimeCookieHashEquals(transaction.CallbackCookieHash, callbackCookie)) + return new(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, transaction?.ReturnRouteKey ?? defaultRoute); + + var claimed = await persistence.ClaimAuthorizationAsync(state, caller.EntraObjectId!, DateTimeOffset.UtcNow, ct) + .ConfigureAwait(false); + if (claimed != AuthorizationClaimResult.Claimed) + return new( + claimed == AuthorizationClaimResult.Consumed + ? RepoAppAuthorizationOutcome.AuthorizationTransactionConsumed + : RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, + transaction.ReturnRouteKey); + + return await CompleteClaimedAsync(transaction, caller.EntraObjectId!, code, ct).ConfigureAwait(false); + } + + private async Task CompleteClaimedAsync( + RepoAppAuthorizationTransaction transaction, + string entraObjectId, + string? code, + CancellationToken ct) + { + string? credentialReference = null; + var completionCommitted = false; + try + { + if (string.IsNullOrWhiteSpace(code)) + { + await CompleteFailureAsync(transaction, entraObjectId, ct).ConfigureAwait(false); + return new(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, transaction.ReturnRouteKey); + } + + var verifierResult = await secretStore.GetSecretAsync(transaction.PkceVerifierProtected, ct).ConfigureAwait(false); + if (!verifierResult.Found || string.IsNullOrWhiteSpace(verifierResult.Value)) + { + await CompleteFailureAsync(transaction, entraObjectId, ct).ConfigureAwait(false); + return new(RepoAppAuthorizationOutcome.GitHubBindingUnavailable, transaction.ReturnRouteKey); + } + + var credential = await ExchangeCodeAsync(code, verifierResult.Value, ct).ConfigureAwait(false); + await WriteTombstoneAsync(transaction.PkceVerifierProtected, ct).ConfigureAwait(false); + if (credential is null) + { + await CompleteFailureAsync(transaction, entraObjectId, ct).ConfigureAwait(false); + return new(RepoAppAuthorizationOutcome.GitHubBindingUnavailable, transaction.ReturnRouteKey); + } + + var credentialVersion = CreateRandomValue(); + credentialReference = $"repo-app-user-credential-{credentialVersion}"; + await secretStore.SetSecretAsync( + credentialReference, + JsonSerializer.Serialize(credential with { Status = CredentialStatusSignedIn }), + ct: ct).ConfigureAwait(false); + var completion = await persistence.CompleteRepoAppAuthorizationAsync( + transaction.State, + new GitHubAppAuthorizationRecord + { + Id = Guid.NewGuid().ToString("N"), + EntraObjectId = entraObjectId, + AppKind = GitHubAppKind.Repo, + Purpose = GitHubAuthorizationPurpose.InteractiveRepository, + CredentialReference = credentialReference, + CredentialVersion = credentialVersion, + GrantDigest = CreateGrantDigest(credentialVersion), + CreatedAt = DateTimeOffset.UtcNow, + }, + CreateAudit(entraObjectId, GitHubAuditOutcome.Succeeded, GitHubAuditReasonCode.None, credentialVersion), + ct).ConfigureAwait(false); + if (!completion.Completed) + throw new InvalidOperationException(); + completionCommitted = true; + + foreach (var previous in completion.RevokedCredentials) + await WriteTombstoneAsync(previous.CredentialReference, ct).ConfigureAwait(false); + return new(RepoAppAuthorizationOutcome.Success, transaction.ReturnRouteKey); + } + catch + { + if (completionCommitted) + return new(RepoAppAuthorizationOutcome.Success, transaction.ReturnRouteKey); + await FinalizeClaimFailureAsync(transaction, entraObjectId, credentialReference).ConfigureAwait(false); + return new(RepoAppAuthorizationOutcome.GitHubBindingUnavailable, transaction.ReturnRouteKey); + } + } + + /// + /// GitHub's top-level callback cannot carry the browser's bearer header. The callback + /// cookie is the server-authenticated callback session binding issued only after an + /// Entra-authenticated begin; the callback never accepts an identity from the browser. + /// + public async Task CompleteBrowserCallbackAsync( + string? state, + string? code, + string? callbackCookie, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(state)) + return new(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, "settings"); + + var transaction = await persistence.GetRepoAppAuthorizationTransactionAsync(state, ct).ConfigureAwait(false); + if (transaction is null || + transaction.AppKind != GitHubAppKind.Repo || + transaction.Purpose != GitHubAuthorizationPurpose.InteractiveRepository || + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > transaction.ExpiresAtUnixMilliseconds || + string.IsNullOrWhiteSpace(callbackCookie) || + !FixedTimeCookieHashEquals(transaction.CallbackCookieHash, callbackCookie)) + return new(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, "settings"); + + var claimed = await persistence.ClaimAuthorizationAsync( + transaction.State, transaction.EntraObjectId, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + if (claimed != AuthorizationClaimResult.Claimed) + return new( + claimed == AuthorizationClaimResult.Consumed + ? RepoAppAuthorizationOutcome.AuthorizationTransactionConsumed + : RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, + transaction.ReturnRouteKey); + + return await CompleteClaimedAsync(transaction, transaction.EntraObjectId, code, ct).ConfigureAwait(false); + } + + public async Task PollAsync( + CallerContext caller, + ClaimsPrincipal principal, + string transactionId, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return new(RepoAppAuthorizationOutcome.HumanEntraSubjectRequired, null); + if (!TryConsumeRateLimit(caller.EntraObjectId!)) + return new(RepoAppAuthorizationOutcome.RateLimited, null); + + var transaction = await persistence.GetAuthorizationTransactionAsync( + transactionId, GitHubAppKind.Repo, caller.EntraObjectId!, ct).ConfigureAwait(false); + return transaction is null + ? new(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid, null) + : new(RepoAppAuthorizationOutcome.Success, ToPublicStatus(transaction.Status)); + } + + public async Task RefreshAsync( + CallerContext caller, + ClaimsPrincipal principal, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return RepoAppAuthorizationOutcome.HumanEntraSubjectRequired; + var reference = await persistence.GetActiveRepoAppCredentialAsync(caller.EntraObjectId!, ct).ConfigureAwait(false); + if (reference is null) + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + + await using var lease = await persistence.TryAcquireRepoAppCredentialLeaseAsync(reference, ct).ConfigureAwait(false); + if (lease is null) + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + + var secret = await secretStore.GetSecretAsync(reference.CredentialReference, ct).ConfigureAwait(false); + var credential = secret.Found ? DeserializeCredential(secret.Value) : null; + if (credential is null || credential.Status != CredentialStatusSignedIn || string.IsNullOrWhiteSpace(credential.RefreshToken)) + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + + var refreshed = await RefreshCredentialAsync(credential, ct).ConfigureAwait(false); + if (refreshed is null) + { + await WriteTombstoneAsync(reference.CredentialReference, ct).ConfigureAwait(false); + var revoked = await persistence.RevokeRepoAppCredentialUnderLeaseAsync( + reference, + CreateAudit(caller.EntraObjectId!, GitHubAuditOutcome.Failed, GitHubAuditReasonCode.BindingUnavailable, reference.CredentialVersion), + ct).ConfigureAwait(false); + await lease.CommitAsync(ct).ConfigureAwait(false); + return revoked + ? RepoAppAuthorizationOutcome.GitHubBindingUnavailable + : RepoAppAuthorizationOutcome.Success; + } + + try + { + await secretStore.SetSecretAsync( + reference.CredentialReference, + JsonSerializer.Serialize(refreshed with { Status = CredentialStatusSignedIn }), + secret.ETag, + ct).ConfigureAwait(false); + await lease.CommitAsync(ct).ConfigureAwait(false); + return RepoAppAuthorizationOutcome.Success; + } + catch (SecretPreconditionFailedException) + { + await MarkRefreshPersistenceFailureAsync(reference, caller.EntraObjectId!, lease).ConfigureAwait(false); + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + } + catch (Exception) when (!ct.IsCancellationRequested) + { + await MarkRefreshPersistenceFailureAsync(reference, caller.EntraObjectId!, lease).ConfigureAwait(false); + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + } + } + + public async Task RevokeAsync( + CallerContext caller, + ClaimsPrincipal principal, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return RepoAppAuthorizationOutcome.HumanEntraSubjectRequired; + IReadOnlyList references; + try + { + references = await RevokeAllWithRetryAsync(caller.EntraObjectId!, ct).ConfigureAwait(false); + } + catch (DbUpdateException) + { + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + } + if (references.Count == 0) + return RepoAppAuthorizationOutcome.GitHubBindingUnavailable; + + foreach (var reference in references) + { + var secret = await secretStore.GetSecretAsync(reference.CredentialReference, ct).ConfigureAwait(false); + var credential = secret.Found ? DeserializeCredential(secret.Value) : null; + if (credential is not null) + await RevokeWithProviderAsync(credential.AccessToken, ct).ConfigureAwait(false); + await WriteTombstoneAsync(reference.CredentialReference, ct).ConfigureAwait(false); + } + return RepoAppAuthorizationOutcome.Success; + } + + public string GetCallbackRedirect(string returnRouteKey, RepoAppAuthorizationOutcome outcome) + { + var frontend = (configuration["Auth:RepoApp:FrontendUrl"] ?? "http://localhost:5173").TrimEnd('/'); + var route = ReturnRoutes.TryGetValue(returnRouteKey, out var candidate) ? candidate : ReturnRoutes["settings"]; + return $"{frontend}{route}?repo_app_auth={ToStateCode(outcome)}"; + } + + public static void SetCallbackCookie(HttpContext context, string callbackCookie) => + context.Response.Cookies.Append(CookieName, callbackCookie, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + MaxAge = TransactionLifetime, + }); + + public static string? ReadCallbackCookie(HttpContext context) => + context.Request.Cookies.TryGetValue(CookieName, out var value) ? value : null; + + public static void ClearCallbackCookie(HttpContext context) => + context.Response.Cookies.Append(CookieName, string.Empty, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + Expires = DateTimeOffset.UnixEpoch, + }); + + public static string CreateS256Challenge(string verifier) => + ToBase64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + + private async Task ExchangeCodeAsync(string code, string verifier, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_clientId) || + string.IsNullOrWhiteSpace(_clientSecret) || + string.IsNullOrWhiteSpace(_callbackUrl)) + return null; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl.TrimEnd('/')}/login/oauth/access_token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = _clientId, + ["client_secret"] = _clientSecret, + ["code"] = code, + ["redirect_uri"] = _callbackUrl, + ["code_verifier"] = verifier, + }), + }; + request.Headers.Accept.ParseAdd("application/json"); + try + { + using var response = await httpClientFactory.CreateClient("github-authz") + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK || response.Content.Headers.ContentLength is > 64 * 1024) + return null; + var body = await ReadBoundedAsync(response.Content, timeout.Token).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(body); + return result is { Error: null, AccessToken: not null } && !string.IsNullOrWhiteSpace(result.AccessToken) + ? new(null, result.AccessToken, result.RefreshToken, result.ExpiresIn is > 0 + ? DateTimeOffset.UtcNow.AddSeconds(result.ExpiresIn.Value) + : null) + : null; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return null; + } + catch (HttpRequestException) + { + return null; + } + catch (JsonException) + { + return null; + } + } + + private async Task RefreshCredentialAsync(RepoAppCredential credential, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret)) + return null; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl.TrimEnd('/')}/login/oauth/access_token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = _clientId, + ["client_secret"] = _clientSecret, + ["grant_type"] = "refresh_token", + ["refresh_token"] = credential.RefreshToken!, + }), + }; + request.Headers.Accept.ParseAdd("application/json"); + try + { + using var response = await httpClientFactory.CreateClient("github-authz") + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK || response.Content.Headers.ContentLength is > 64 * 1024) + return null; + var body = await ReadBoundedAsync(response.Content, timeout.Token).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(body); + return result is { Error: null, AccessToken: not null } && !string.IsNullOrWhiteSpace(result.AccessToken) + ? credential with + { + AccessToken = result.AccessToken, + RefreshToken = string.IsNullOrWhiteSpace(result.RefreshToken) ? credential.RefreshToken : result.RefreshToken, + ExpiresAt = result.ExpiresIn is > 0 ? DateTimeOffset.UtcNow.AddSeconds(result.ExpiresIn.Value) : null, + } + : null; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return null; + } + catch (HttpRequestException) + { + return null; + } + catch (JsonException) + { + return null; + } + } + + private async Task RevokeWithProviderAsync(string? accessToken, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(accessToken) || string.IsNullOrWhiteSpace(_clientId) || string.IsNullOrWhiteSpace(_clientSecret)) + return; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_baseUrl.TrimEnd('/')}/applications/{Uri.EscapeDataString(_clientId)}/grant") + { + Content = new StringContent(JsonSerializer.Serialize(new { access_token = accessToken }), Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}"))); + try + { + using var _ = await httpClientFactory.CreateClient("github-authz") + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { } + catch (HttpRequestException) { } + } + + private async Task CompleteFailureAsync(RepoAppAuthorizationTransaction transaction, string entraObjectId, CancellationToken ct) => + await persistence.CompleteRepoAppAuthorizationFailureAsync( + transaction.State, + CreateAudit(entraObjectId, GitHubAuditOutcome.Failed, GitHubAuditReasonCode.TransactionInvalid, null), + ct).ConfigureAwait(false); + + private async Task FinalizeClaimFailureAsync( + RepoAppAuthorizationTransaction transaction, + string entraObjectId, + string? credentialReference) + { + persistence.ClearPendingChanges(); + try { await WriteTombstoneAsync(transaction.PkceVerifierProtected, CancellationToken.None).ConfigureAwait(false); } + catch { } + if (!string.IsNullOrWhiteSpace(credentialReference)) + { + try { await WriteTombstoneAsync(credentialReference, CancellationToken.None).ConfigureAwait(false); } + catch { } + } + try { await CompleteFailureAsync(transaction, entraObjectId, CancellationToken.None).ConfigureAwait(false); } + catch { } + } + + private async Task MarkRefreshPersistenceFailureAsync( + RepoAppCredentialReference reference, + string entraObjectId, + RepoAppCredentialLease lease) + { + try { await WriteTombstoneAsync(reference.CredentialReference, CancellationToken.None).ConfigureAwait(false); } + catch { } + await persistence.RevokeRepoAppCredentialUnderLeaseAsync( + reference, + CreateAudit(entraObjectId, GitHubAuditOutcome.Failed, GitHubAuditReasonCode.BindingUnavailable, reference.CredentialVersion), + CancellationToken.None).ConfigureAwait(false); + await lease.CommitAsync(CancellationToken.None).ConfigureAwait(false); + } + + private async Task> RevokeAllWithRetryAsync( + string entraObjectId, + CancellationToken ct) + { + for (var attempt = 0; ; attempt++) + { + try + { + return await persistence.RevokeRepoAppCredentialsAsync( + entraObjectId, + CreateAudit(entraObjectId, GitHubAuditOutcome.Succeeded, GitHubAuditReasonCode.None, null), + ct).ConfigureAwait(false); + } + catch (DbUpdateException ex) when (attempt < 2 && IsRetryableConcurrencyFailure(ex)) + { + await Task.Delay(TimeSpan.FromMilliseconds(25 * (attempt + 1)), ct).ConfigureAwait(false); + } + } + } + + private static bool IsRetryableConcurrencyFailure(DbUpdateException exception) => + exception.InnerException is PostgresException + { + SqlState: PostgresErrorCodes.SerializationFailure or PostgresErrorCodes.DeadlockDetected + }; + + private async Task WriteTombstoneAsync(string reference, CancellationToken ct) => + await secretStore.SetSecretAsync(reference, JsonSerializer.Serialize(new RepoAppCredential(CredentialStatusRevoked, null, null, null)), ct: ct) + .ConfigureAwait(false); + + private static RepoAppCredential? DeserializeCredential(string? value) + { + try { return string.IsNullOrWhiteSpace(value) ? null : JsonSerializer.Deserialize(value); } + catch (JsonException) { return null; } + } + + private static GitHubAuditRecord CreateAudit( + string entraObjectId, + GitHubAuditOutcome outcome, + GitHubAuditReasonCode reason, + string? credentialVersion) => + new() + { + EntraObjectId = entraObjectId, + ActorKind = GitHubAuditActorKind.HumanEntraSubject, + Action = GitHubAuditAction.AuthorizationCompleted, + ResourceId = credentialVersion ?? "repo-app-authorization", + AppKind = GitHubAppKind.Repo, + Purpose = GitHubAuthorizationPurpose.InteractiveRepository, + Outcome = outcome, + ReasonCode = reason, + CorrelationId = Guid.NewGuid().ToString("N"), + OccurredAt = DateTimeOffset.UtcNow, + CredentialVersionOrDigest = credentialVersion, + }; + + private static bool TryConsumeRateLimit(string entraObjectId) + { + var now = DateTimeOffset.UtcNow; + var window = RateWindows.AddOrUpdate( + entraObjectId, + _ => new RateWindow(now, 1), + (_, existing) => now - existing.Start >= TimeSpan.FromMinutes(1) + ? new RateWindow(now, 1) + : new RateWindow(existing.Start, existing.Count + 1)); + return window.Count <= 20; + } + + private static string ToPublicStatus(GitHubAuthorizationStatus status) => status switch + { + GitHubAuthorizationStatus.Pending => "pending", + GitHubAuthorizationStatus.Redeeming => "pending", + GitHubAuthorizationStatus.Completed => "completed", + GitHubAuthorizationStatus.Failed => "failed", + GitHubAuthorizationStatus.Expired => "expired", + _ => "failed", + }; + + public static string ToStateCode(RepoAppAuthorizationOutcome outcome) => outcome switch + { + RepoAppAuthorizationOutcome.HumanEntraSubjectRequired => "human_entra_subject_required", + RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid => "authorization_transaction_invalid", + RepoAppAuthorizationOutcome.AuthorizationTransactionConsumed => "authorization_transaction_consumed", + RepoAppAuthorizationOutcome.GitHubBindingUnavailable => "github_binding_unavailable", + RepoAppAuthorizationOutcome.RateLimited => "rate_limited", + _ => "success", + }; + + private static string CreateRandomValue() => ToBase64Url(RandomNumberGenerator.GetBytes(32)); + + private static string HashCookie(string callbackCookie) => + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(callbackCookie))); + + private static bool FixedTimeCookieHashEquals(string expectedHash, string callbackCookie) + { + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(expectedHash), + SHA256.HashData(Encoding.UTF8.GetBytes(callbackCookie))); + } + catch (FormatException) + { + return false; + } + } + + private static string CreateGrantDigest(string credentialVersion) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"repo:{credentialVersion}"))).ToLowerInvariant(); + + private static string ToBase64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static async Task ReadBoundedAsync(HttpContent content, CancellationToken ct) + { + await using var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (true) + { + var read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false); + if (read == 0) + return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + if (buffer.Length + read > 64 * 1024) + throw new JsonException(); + buffer.Write(chunk, 0, read); + } + } + + private sealed record RateWindow(DateTimeOffset Start, int Count); + private sealed record RepoAppCredential(string? Status, string? AccessToken, string? RefreshToken, DateTimeOffset? ExpiresAt); + private sealed class ProviderTokenResponse + { + [System.Text.Json.Serialization.JsonPropertyName("access_token")] public string? AccessToken { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] public long? ExpiresIn { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("error")] public string? Error { get; init; } + } +} diff --git a/apps/Agentweaver.Api/Auth/TwoAppPersistenceStore.cs b/apps/Agentweaver.Api/Auth/TwoAppPersistenceStore.cs index 971158e3b..44f4f8cae 100644 --- a/apps/Agentweaver.Api/Auth/TwoAppPersistenceStore.cs +++ b/apps/Agentweaver.Api/Auth/TwoAppPersistenceStore.cs @@ -11,6 +11,41 @@ namespace Agentweaver.Api.Auth; public enum AuthorizationClaimResult { Claimed, Invalid, Consumed } public enum BindingWriteResult { Bound, Unavailable } public enum InvocationClaimResult { Claimed, Duplicate } +internal sealed record RepoAppAuthorizationTransaction( + string State, + GitHubAppKind AppKind, + GitHubAuthorizationPurpose Purpose, + string EntraObjectId, + long ExpiresAtUnixMilliseconds, + string ReturnRouteKey, + string PkceVerifierProtected, + string CallbackCookieHash); +internal sealed record RepoAppCredentialReference( + string Id, + string CredentialReference, + string CredentialVersion, + DateTimeOffset CreatedAt); +internal sealed record RepoAppAuthorizationCompletion( + bool Completed, + IReadOnlyList RevokedCredentials); +internal sealed class RepoAppCredentialLease( + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction transaction) : IAsyncDisposable +{ + private bool _completed; + + public async Task CommitAsync(CancellationToken ct) + { + await transaction.CommitAsync(ct).ConfigureAwait(false); + _completed = true; + } + + public async ValueTask DisposeAsync() + { + if (!_completed) + await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + await transaction.DisposeAsync().ConfigureAwait(false); + } +} /// /// Persistence boundary for the two GitHub App model. It accepts only opaque credential @@ -68,6 +103,22 @@ public async Task ClaimAuthorizationAsync( : AuthorizationClaimResult.Invalid; } + internal Task GetRepoAppAuthorizationTransactionAsync( + string state, + CancellationToken ct = default) => + db.GitHubAuthorizations.AsNoTracking() + .Where(x => x.State == state) + .Select(x => new RepoAppAuthorizationTransaction( + x.State, + x.AppKind, + x.Purpose, + x.EntraObjectId, + x.ExpiresAtUnixMilliseconds, + x.ReturnRouteKey, + x.PkceVerifierProtected, + x.CallbackCookieHash)) + .SingleOrDefaultAsync(ct); + public async Task ClaimAuthorizationByTransactionIdAsync( string transactionId, GitHubAppKind appKind, @@ -116,10 +167,22 @@ public async Task ClaimAuthorizationByTransactionIdAsy x.Status)) .SingleOrDefaultAsync(ct) .ConfigureAwait(false); - return transaction is { Status: GitHubAuthorizationStatus.Pending } && - transaction.ExpiresAt < DateTimeOffset.UtcNow - ? transaction with { Status = GitHubAuthorizationStatus.Expired } - : transaction; + if (transaction is null || transaction.ExpiresAt >= DateTimeOffset.UtcNow || + transaction.Status is not (GitHubAuthorizationStatus.Pending or GitHubAuthorizationStatus.Redeeming)) + return transaction; + + var completedAt = DateTimeOffset.UtcNow; + await db.GitHubAuthorizations + .Where(x => x.ExternalTransactionId == transactionId && + x.AppKind == appKind && + x.EntraObjectId == entraObjectId && + (x.Status == GitHubAuthorizationStatus.Pending || x.Status == GitHubAuthorizationStatus.Redeeming) && + x.ExpiresAtUnixMilliseconds < completedAt.ToUnixTimeMilliseconds()) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, GitHubAuthorizationStatus.Expired) + .SetProperty(x => x.CompletedAt, completedAt), ct) + .ConfigureAwait(false); + return transaction with { Status = GitHubAuthorizationStatus.Expired }; } public Task CompleteAuthorizationAsync( @@ -132,6 +195,204 @@ public Task CompleteAuthorizationAsync( .SetProperty(x => x.Status, succeeded ? GitHubAuthorizationStatus.Completed : GitHubAuthorizationStatus.Failed) .SetProperty(x => x.CompletedAt, DateTimeOffset.UtcNow), ct); + internal async Task CompleteRepoAppAuthorizationAsync( + string state, + GitHubAppAuthorizationRecord authorization, + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(authorization); + EnsureSafe(audit); + var completedAt = DateTimeOffset.UtcNow; + await using var transaction = await db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct).ConfigureAwait(false); + db.GitHubAppAuthorizations.Add(authorization); + db.GitHubAuditRecords.Add(audit); + var revokedCredentials = await GetActiveRepoAppCredentialsAsync(authorization.EntraObjectId, ct) + .ConfigureAwait(false); + await db.GitHubAppAuthorizations + .Where(x => x.EntraObjectId == authorization.EntraObjectId && + x.AppKind == GitHubAppKind.Repo && + x.Purpose == GitHubAuthorizationPurpose.InteractiveRepository && + x.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, completedAt), ct) + .ConfigureAwait(false); + var changed = await db.GitHubAuthorizations + .Where(x => x.State == state && x.Status == GitHubAuthorizationStatus.Redeeming) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, GitHubAuthorizationStatus.Completed) + .SetProperty(x => x.CompletedAt, completedAt), ct) + .ConfigureAwait(false); + if (changed != 1) + { + await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + db.ChangeTracker.Clear(); + return new(false, []); + } + + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + return new(true, revokedCredentials); + } + + internal async Task CompleteRepoAppAuthorizationFailureAsync( + string state, + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(audit); + var completedAt = DateTimeOffset.UtcNow; + await using var transaction = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false); + db.GitHubAuditRecords.Add(audit); + await db.GitHubAuthorizations + .Where(x => x.State == state && x.Status == GitHubAuthorizationStatus.Redeeming) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, GitHubAuthorizationStatus.Failed) + .SetProperty(x => x.CompletedAt, completedAt), ct) + .ConfigureAwait(false); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + } + + internal async Task GetActiveRepoAppCredentialAsync( + string entraObjectId, + CancellationToken ct = default) + { + var candidates = await db.GitHubAppAuthorizations.AsNoTracking() + .Where(x => x.EntraObjectId == entraObjectId && + x.AppKind == GitHubAppKind.Repo && + x.Purpose == GitHubAuthorizationPurpose.InteractiveRepository && + x.RevokedAt == null) + .Select(x => new RepoAppCredentialReference( + x.Id, x.CredentialReference, x.CredentialVersion, x.CreatedAt)) + .ToListAsync(ct).ConfigureAwait(false); + return candidates.OrderByDescending(x => x.CreatedAt).FirstOrDefault(); + } + + internal async Task> GetActiveRepoAppCredentialsAsync( + string entraObjectId, + CancellationToken ct = default) => + await db.GitHubAppAuthorizations.AsNoTracking() + .Where(x => x.EntraObjectId == entraObjectId && + x.AppKind == GitHubAppKind.Repo && + x.Purpose == GitHubAuthorizationPurpose.InteractiveRepository && + x.RevokedAt == null) + .Select(x => new RepoAppCredentialReference( + x.Id, x.CredentialReference, x.CredentialVersion, x.CreatedAt)) + .ToListAsync(ct).ConfigureAwait(false); + + internal async Task> RevokeRepoAppCredentialsAsync( + string entraObjectId, + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(audit); + var revokedAt = DateTimeOffset.UtcNow; + await using var transaction = await db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct).ConfigureAwait(false); + db.GitHubAuditRecords.Add(audit); + var revokedCredentials = await GetActiveRepoAppCredentialsAsync(entraObjectId, ct).ConfigureAwait(false); + var changed = await db.GitHubAppAuthorizations + .Where(x => x.EntraObjectId == entraObjectId && + x.AppKind == GitHubAppKind.Repo && + x.Purpose == GitHubAuthorizationPurpose.InteractiveRepository && + x.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, revokedAt), ct) + .ConfigureAwait(false); + await db.GitHubAuthorizations + .Where(x => x.EntraObjectId == entraObjectId && + x.AppKind == GitHubAppKind.Repo && + x.Purpose == GitHubAuthorizationPurpose.InteractiveRepository && + (x.Status == GitHubAuthorizationStatus.Pending || x.Status == GitHubAuthorizationStatus.Redeeming)) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, GitHubAuthorizationStatus.Failed) + .SetProperty(x => x.CompletedAt, revokedAt), ct) + .ConfigureAwait(false); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + return changed == 0 ? [] : revokedCredentials; + } + + internal async Task RevokeRepoAppCredentialIfCurrentAsync( + RepoAppCredentialReference credential, + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(audit); + var revokedAt = DateTimeOffset.UtcNow; + await using var transaction = await db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct).ConfigureAwait(false); + db.GitHubAuditRecords.Add(audit); + var changed = await db.GitHubAppAuthorizations + .Where(x => x.Id == credential.Id && + x.CredentialReference == credential.CredentialReference && + x.CredentialVersion == credential.CredentialVersion && + x.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, revokedAt), ct) + .ConfigureAwait(false); + if (changed == 0) + { + await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + db.ChangeTracker.Clear(); + return false; + } + + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + return true; + } + + internal async Task TryAcquireRepoAppCredentialLeaseAsync( + RepoAppCredentialReference credential, + CancellationToken ct = default) + { + var transaction = await db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct).ConfigureAwait(false); + try + { + var changed = await db.GitHubAppAuthorizations + .Where(x => x.Id == credential.Id && + x.CredentialReference == credential.CredentialReference && + x.CredentialVersion == credential.CredentialVersion && + x.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CredentialReference, x => x.CredentialReference), ct) + .ConfigureAwait(false); + if (changed == 1) + return new RepoAppCredentialLease(transaction); + + await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + await transaction.DisposeAsync().ConfigureAwait(false); + return null; + } + catch + { + await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + await transaction.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + internal async Task RevokeRepoAppCredentialUnderLeaseAsync( + RepoAppCredentialReference credential, + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(audit); + db.GitHubAuditRecords.Add(audit); + var revokedAt = DateTimeOffset.UtcNow; + var changed = await db.GitHubAppAuthorizations + .Where(x => x.Id == credential.Id && + x.CredentialReference == credential.CredentialReference && + x.CredentialVersion == credential.CredentialVersion && + x.RevokedAt == null) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, revokedAt), ct) + .ConfigureAwait(false); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + return changed == 1; + } + + internal void ClearPendingChanges() => db.ChangeTracker.Clear(); + public async Task ReplaceCopilotBindingAsync( ProjectCopilotBindingRecord binding, CancellationToken ct = default) diff --git a/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs b/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs index 19ad30505..2d0e6508d 100644 --- a/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs +++ b/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs @@ -87,6 +87,115 @@ public static void MapAuthEndpoints(this WebApplication app) }); }); +// Repo App authorization is isolated from the legacy GitHub OAuth and device-flow lanes. Its +// transaction handle is safe to expose for polling; OAuth state, PKCE verifier, and cookie secret +// remain server-side. +app.MapPost("/api/auth/github/repo-app/authorizations", async ( + HttpContext httpContext, + RepoAppAuthorizationBeginRequest? request, + IConfiguration configuration, + TwoAppPersistenceStore persistence, + ISecretStore secretStore, + IHttpClientFactory httpClientFactory, + CancellationToken ct) => +{ + var repoAppAuthorization = new RepoAppUserAuthorizationService( + configuration, persistence, secretStore, httpClientFactory); + var result = await repoAppAuthorization.BeginAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), + httpContext.User, + request?.ReturnRouteKey, + ct).ConfigureAwait(false); + if (result.Outcome != RepoAppAuthorizationOutcome.Success) + return Results.Conflict(new { error = RepoAppUserAuthorizationService.ToStateCode(result.Outcome) }); + + RepoAppUserAuthorizationService.SetCallbackCookie(httpContext, result.CallbackCookie!); + return Results.Ok(new + { + authorization_url = result.AuthorizationUrl, + transaction_id = result.TransactionId, + expires_at = result.ExpiresAt, + }); +}); + +// This callback is deliberately outside /api: GitHub navigates the browser here and cannot send +// its bearer header. The one-time, HttpOnly callback cookie is bound to a server-side Entra subject +// established at the authenticated begin endpoint; callback input cannot select a subject. +app.MapGet("/auth/github/repo-app/callback", async ( + HttpContext httpContext, + string? code, + string? state, + string? error, + IConfiguration configuration, + TwoAppPersistenceStore persistence, + ISecretStore secretStore, + IHttpClientFactory httpClientFactory, + CancellationToken ct) => +{ + var repoAppAuthorization = new RepoAppUserAuthorizationService( + configuration, persistence, secretStore, httpClientFactory); + var callbackCookie = RepoAppUserAuthorizationService.ReadCallbackCookie(httpContext); + RepoAppUserAuthorizationService.ClearCallbackCookie(httpContext); + var result = await repoAppAuthorization.CompleteBrowserCallbackAsync( + state, + string.IsNullOrWhiteSpace(error) ? code : null, + callbackCookie, + ct).ConfigureAwait(false); + return Results.Redirect(repoAppAuthorization.GetCallbackRedirect(result.ReturnRouteKey, result.Outcome)); +}).AllowAnonymous(); + +app.MapGet("/api/auth/github/repo-app/authorizations/{transactionId}", async ( + HttpContext httpContext, + string transactionId, + IConfiguration configuration, + TwoAppPersistenceStore persistence, + ISecretStore secretStore, + IHttpClientFactory httpClientFactory, + CancellationToken ct) => +{ + var repoAppAuthorization = new RepoAppUserAuthorizationService( + configuration, persistence, secretStore, httpClientFactory); + var result = await repoAppAuthorization.PollAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), httpContext.User, transactionId, ct).ConfigureAwait(false); + return result.Outcome == RepoAppAuthorizationOutcome.Success + ? Results.Ok(new { status = result.Status }) + : Results.Conflict(new { error = RepoAppUserAuthorizationService.ToStateCode(result.Outcome) }); +}); + +app.MapPost("/api/auth/github/repo-app/authorization/refresh", async ( + HttpContext httpContext, + IConfiguration configuration, + TwoAppPersistenceStore persistence, + ISecretStore secretStore, + IHttpClientFactory httpClientFactory, + CancellationToken ct) => +{ + var repoAppAuthorization = new RepoAppUserAuthorizationService( + configuration, persistence, secretStore, httpClientFactory); + var outcome = await repoAppAuthorization.RefreshAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), httpContext.User, ct).ConfigureAwait(false); + return outcome == RepoAppAuthorizationOutcome.Success + ? Results.NoContent() + : Results.Conflict(new { error = RepoAppUserAuthorizationService.ToStateCode(outcome) }); +}); + +app.MapDelete("/api/auth/github/repo-app/authorization", async ( + HttpContext httpContext, + IConfiguration configuration, + TwoAppPersistenceStore persistence, + ISecretStore secretStore, + IHttpClientFactory httpClientFactory, + CancellationToken ct) => +{ + var repoAppAuthorization = new RepoAppUserAuthorizationService( + configuration, persistence, secretStore, httpClientFactory); + var outcome = await repoAppAuthorization.RevokeAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), httpContext.User, ct).ConfigureAwait(false); + return outcome == RepoAppAuthorizationOutcome.Success + ? Results.NoContent() + : Results.Conflict(new { error = RepoAppUserAuthorizationService.ToStateCode(outcome) }); +}); + // GET /auth/github/authorize — begin OAuth redirect flow app.MapGet("/auth/github/authorize", async (HttpContext httpContext, GitHubOAuthRedirectService oauthService, CancellationToken ct) => { @@ -872,3 +981,7 @@ file sealed record SessionExchangeResponse( [property: System.Text.Json.Serialization.JsonPropertyName("session_token")] string SessionToken, [property: System.Text.Json.Serialization.JsonPropertyName("login")] string Login ); + +file sealed record RepoAppAuthorizationBeginRequest( + [property: System.Text.Json.Serialization.JsonPropertyName("return_route_key")] string? ReturnRouteKey +); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f1a718278..6e74bbecd 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -42,6 +42,30 @@ cd apps/Agentweaver.Api dotnet user-secrets set "Auth:GitHub:ClientSecret" "" ``` +#### Repo App user authorization + +Interactive repository access is authorized separately from product sign-in and from the +legacy GitHub OAuth configuration. An Entra-authenticated human starts +`POST /api/auth/github/repo-app/authorizations`; the browser completes the App callback at +`GET /auth/github/repo-app/callback`. The API persists only opaque transaction and credential +references. It uses PKCE S256 and a one-time `__Host-` callback cookie; do not register the +legacy `/auth/github/callback` URL for this App. + +| Key | Default | Purpose | +| --- | --- | --- | +| `Auth:RepoApp:ClientId` | none | Repo GitHub App OAuth client ID | +| `Auth:RepoApp:ClientSecret` | none | Repo GitHub App OAuth client secret; set through user-secrets or Key Vault | +| `Auth:RepoApp:CallbackUrl` | none | Exact registered callback URL, ending in `/auth/github/repo-app/callback` | +| `Auth:RepoApp:BaseUrl` | `https://github.com` | GitHub authorization origin | +| `Auth:RepoApp:Scopes` | `repo read:user` | Explicit user-authorization scopes | +| `Auth:RepoApp:FrontendUrl` | `http://localhost:5173` | Trusted application origin for fixed post-callback routes | + +The begin request accepts only `settings` or `projects` as `return_route_key`; it never +accepts an arbitrary URL or path. Refresh and disconnect use the corresponding +`POST /api/auth/github/repo-app/authorization/refresh` and +`DELETE /api/auth/github/repo-app/authorization` endpoints. Both require the same +human Entra subject as authorization begin. + When `Auth:Mode=Entra`, the platform sign-in is driven by Microsoft Entra ID instead of GitHub. The interactive browser flow (`/auth/entra/authorize` → `/auth/entra/callback`) uses the Microsoft identity platform v2.0 authorization-code-with-PKCE flow. Agentweaver diff --git a/docs/reference/api.md b/docs/reference/api.md index bf10705d2..fe67a4f2e 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -178,6 +178,11 @@ Agent loopback writes authenticate with the normal internal API key plus a run-s | `GET` | `/api/auth/github-accounts` | List the caller's linked GitHub accounts (default flag, avatar, Copilot status, linked time) | | `POST` | `/api/auth/github-accounts/link` | Start a second GitHub OAuth round-trip that links another GitHub account to the current Entra user | | `DELETE` | `/api/auth/github-accounts/{login}` | Unlink one linked GitHub account; if it was default, the store promotes the next remaining linked account | +| `POST` | `/api/auth/github/repo-app/authorizations` | Begin an Entra-user-bound Repo App authorization; returns an authorization URL and opaque transaction ID | +| `GET` | `/auth/github/repo-app/callback` | Complete the Repo App browser callback with its one-time callback cookie | +| `GET` | `/api/auth/github/repo-app/authorizations/{transactionId}` | Return only the initiating subject's safe transaction status | +| `POST` | `/api/auth/github/repo-app/authorization/refresh` | Refresh the caller's Repo App authorization without changing its grant identity | +| `DELETE` | `/api/auth/github/repo-app/authorization` | Revoke the caller's Repo App authorization and write a credential tombstone | | `PUT` | `/api/auth/github-accounts/{login}/default` | Make a linked GitHub account the caller's default account | | `GET` | `/api/auth/github-accounts/accessible-repos` | Enumerate repositories reachable across all linked GitHub accounts, tagged with the login and GitHub-reported permission level | | `GET` | `/api/github/accounts` | List the signed-in user's personal account followed by organizations | diff --git a/tests/Agentweaver.Tests/Auth/RepoAppUserAuthorizationServiceTests.cs b/tests/Agentweaver.Tests/Auth/RepoAppUserAuthorizationServiceTests.cs new file mode 100644 index 000000000..d52e7094a --- /dev/null +++ b/tests/Agentweaver.Tests/Auth/RepoAppUserAuthorizationServiceTests.cs @@ -0,0 +1,390 @@ +using System.Net; +using System.Security.Claims; +using System.Text; +using Agentweaver.Api.Auth; +using Agentweaver.Api.Memory; +using Agentweaver.Api.Security; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace Agentweaver.Tests.Auth; + +public sealed class RepoAppUserAuthorizationServiceTests +{ + [Fact] + public async Task Begin_UsesPkceS256AndAnOpaqueAllowlistedReturnRoute() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory()); + + var result = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + + result.Outcome.Should().Be(RepoAppAuthorizationOutcome.Success); + result.TransactionId.Should().HaveLength(43); + result.AuthorizationUrl.Should().Contain("code_challenge_method=S256"); + result.AuthorizationUrl.Should().NotContain(result.TransactionId); + result.CallbackCookie.Should().HaveLength(43); + + var state = Query(result.AuthorizationUrl!, "state"); + var challenge = Query(result.AuthorizationUrl!, "code_challenge"); + var stored = await database.GitHubAuthorizations.SingleAsync(); + stored.State.Should().Be(state); + stored.ExternalTransactionId.Should().Be(result.TransactionId); + stored.ReturnRouteKey.Should().Be("settings"); + stored.PkceVerifierProtected.Should().NotBeNullOrWhiteSpace(); + stored.CallbackCookieHash.Should().NotBe(result.CallbackCookie); + var verifier = await secrets.GetSecretAsync(stored.PkceVerifierProtected); + RepoAppUserAuthorizationService.CreateS256Challenge(verifier.Value!).Should().Be(challenge); + + var rejected = await service.BeginAsync(Human("another-entra"), HumanPrincipal(), "https://attacker.invalid"); + rejected.Outcome.Should().Be(RepoAppAuthorizationOutcome.GitHubBindingUnavailable); + } + + [Fact] + public async Task Callback_RejectsWrongMissingCookieAndWrongSubjectWithoutRedeeming() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var factory = new StubHttpClientFactory(); + var service = CreateService(database, secrets, factory); + var begin = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + var state = Query(begin.AuthorizationUrl!, "state"); + + (await service.CompleteAsync(Human("entra"), HumanPrincipal(), state, "code", null)) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid); + (await service.CompleteAsync(Human("entra"), HumanPrincipal(), state, "code", "wrong")) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid); + (await service.CompleteAsync(Human("other"), HumanPrincipal(), state, "code", begin.CallbackCookie)) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid); + + (await database.GitHubAuthorizations.SingleAsync()).Status.Should().Be(GitHubAuthorizationStatus.Pending); + factory.RequestBodies.Should().BeEmpty(); + } + + [Fact] + public async Task Callback_RejectsExpiredAndWrongPurposeTransactions() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory()); + + await new TwoAppPersistenceStore(database).AddAuthorizationAsync(Transaction( + "expired", "expired-id", "entra", GitHubAppKind.Repo, + GitHubAuthorizationPurpose.InteractiveRepository, DateTimeOffset.UtcNow.AddMinutes(-1))); + await new TwoAppPersistenceStore(database).AddAuthorizationAsync(Transaction( + "wrong-purpose", "purpose-id", "entra", GitHubAppKind.Repo, + GitHubAuthorizationPurpose.InteractiveCopilot, DateTimeOffset.UtcNow.AddMinutes(1))); + await new TwoAppPersistenceStore(database).AddAuthorizationAsync(Transaction( + "wrong-app", "app-id", "entra", GitHubAppKind.Copilot, + GitHubAuthorizationPurpose.InteractiveRepository, DateTimeOffset.UtcNow.AddMinutes(1))); + + (await service.CompleteAsync(Human("entra"), HumanPrincipal(), "expired", "code", "cookie")) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid); + (await service.CompleteAsync(Human("entra"), HumanPrincipal(), "wrong-purpose", "code", "cookie")) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid); + (await service.CompleteAsync(Human("entra"), HumanPrincipal(), "wrong-app", "code", "cookie")) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionInvalid); + } + + [Fact] + public async Task Poll_ExpiresAnInterruptedRedeemingTransaction() + { + await using var database = await OpenDatabaseAsync(); + var record = Transaction( + "redeeming-state", + "redeeming-id", + "entra", + GitHubAppKind.Repo, + GitHubAuthorizationPurpose.InteractiveRepository, + DateTimeOffset.UtcNow.AddMinutes(-1)); + record.Status = GitHubAuthorizationStatus.Redeeming; + await new TwoAppPersistenceStore(database).AddAuthorizationAsync(record); + var service = CreateService(database, new InMemorySecretStore(), new StubHttpClientFactory()); + + var result = await service.PollAsync(Human("entra"), HumanPrincipal(), "redeeming-id"); + + result.Status.Should().Be("expired"); + database.ChangeTracker.Clear(); + (await database.GitHubAuthorizations.SingleAsync()).Status.Should().Be(GitHubAuthorizationStatus.Expired); + } + + [Fact] + public async Task Callback_IsSingleUseIncludingConcurrentRedemption() + { + await using var connection = await OpenConnectionAsync(); + var options = Options(connection); + await using var setup = new MemoryDbContext(options); + await setup.Database.EnsureCreatedAsync(); + var secrets = new InMemorySecretStore(); + var first = CreateService(setup, secrets, new StubHttpClientFactory(TokenResponse())); + var begin = await first.BeginAsync(Human("entra"), HumanPrincipal(), "projects"); + var state = Query(begin.AuthorizationUrl!, "state"); + + await using var secondDb = new MemoryDbContext(options); + var second = CreateService(secondDb, secrets, new StubHttpClientFactory(TokenResponse())); + var results = await Task.WhenAll( + first.CompleteAsync(Human("entra"), HumanPrincipal(), state, "code", begin.CallbackCookie), + second.CompleteAsync(Human("entra"), HumanPrincipal(), state, "code", begin.CallbackCookie)); + + results.Count(x => x.Outcome == RepoAppAuthorizationOutcome.Success).Should().Be(1); + results.Count(x => x.Outcome == RepoAppAuthorizationOutcome.AuthorizationTransactionConsumed).Should().Be(1); + (await setup.GitHubAppAuthorizations.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Refresh_PreservesStableGrantVersion_AndRevokeWritesTombstone() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory( + TokenResponse(), + TokenResponse("ghu_refreshed", "refresh-rotated"))); + var begin = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + var completed = await service.CompleteAsync( + Human("entra"), HumanPrincipal(), Query(begin.AuthorizationUrl!, "state"), "code", begin.CallbackCookie); + completed.Outcome.Should().Be(RepoAppAuthorizationOutcome.Success); + + var grant = await database.GitHubAppAuthorizations.SingleAsync(); + var beforeRefresh = await secrets.GetSecretAsync(grant.CredentialReference); + beforeRefresh.Value.Should().Contain("ghu_access").And.Contain("refresh-original"); + + (await service.RefreshAsync(Human("entra"), HumanPrincipal())).Should().Be(RepoAppAuthorizationOutcome.Success); + var afterRefresh = await secrets.GetSecretAsync(grant.CredentialReference); + afterRefresh.Value.Should().Contain("ghu_refreshed").And.Contain("refresh-rotated"); + (await database.GitHubAppAuthorizations.SingleAsync()).CredentialVersion.Should().Be(grant.CredentialVersion); + + (await service.RevokeAsync(Human("entra"), HumanPrincipal())).Should().Be(RepoAppAuthorizationOutcome.Success); + (await secrets.GetSecretAsync(grant.CredentialReference)).Value.Should().Contain("revoked") + .And.NotContain("ghu_").And.NotContain("refresh-"); + database.ChangeTracker.Clear(); + (await database.GitHubAppAuthorizations.SingleAsync()).RevokedAt.Should().NotBeNull(); + } + + [Fact] + public async Task ReauthorizationAndDisconnect_RevokeEveryPriorRepoAppCredential() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory()); + + var first = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + await service.CompleteAsync( + Human("entra"), HumanPrincipal(), Query(first.AuthorizationUrl!, "state"), "first-code", first.CallbackCookie); + database.ChangeTracker.Clear(); + var firstCredential = await database.GitHubAppAuthorizations.SingleAsync(); + + var second = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + await service.CompleteAsync( + Human("entra"), HumanPrincipal(), Query(second.AuthorizationUrl!, "state"), "second-code", second.CallbackCookie); + database.ChangeTracker.Clear(); + var afterReplacement = await database.GitHubAppAuthorizations.ToListAsync(); + afterReplacement.Should().HaveCount(2); + afterReplacement.Single(x => x.Id == firstCredential.Id).RevokedAt.Should().NotBeNull(); + (await secrets.GetSecretAsync(firstCredential.CredentialReference)).Value.Should().Contain("revoked"); + + (await service.RevokeAsync(Human("entra"), HumanPrincipal())).Should().Be(RepoAppAuthorizationOutcome.Success); + database.ChangeTracker.Clear(); + (await database.GitHubAppAuthorizations.CountAsync(x => x.RevokedAt == null)).Should().Be(0); + foreach (var credential in await database.GitHubAppAuthorizations.ToListAsync()) + (await secrets.GetSecretAsync(credential.CredentialReference)).Value.Should().Contain("revoked"); + } + + [Fact] + public async Task Disconnect_InvalidatesOutstandingRepoAppTransactions() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory()); + var active = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + await service.CompleteAsync( + Human("entra"), HumanPrincipal(), Query(active.AuthorizationUrl!, "state"), "active-code", active.CallbackCookie); + var pending = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + + (await service.RevokeAsync(Human("entra"), HumanPrincipal())).Should().Be(RepoAppAuthorizationOutcome.Success); + database.ChangeTracker.Clear(); + (await database.GitHubAuthorizations.SingleAsync( + x => x.State == Query(pending.AuthorizationUrl!, "state"))).Status.Should().Be(GitHubAuthorizationStatus.Failed); + (await service.CompleteAsync( + Human("entra"), HumanPrincipal(), Query(pending.AuthorizationUrl!, "state"), "late-code", pending.CallbackCookie)) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.AuthorizationTransactionConsumed); + } + + [Fact] + public async Task PostClaimFailure_FinalizesTheTransactionAndTombstonesVerifier() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new ThrowingGetSecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory()); + var begin = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + var state = Query(begin.AuthorizationUrl!, "state"); + + (await service.CompleteAsync(Human("entra"), HumanPrincipal(), state, "code", begin.CallbackCookie)) + .Outcome.Should().Be(RepoAppAuthorizationOutcome.GitHubBindingUnavailable); + database.ChangeTracker.Clear(); + var transaction = await database.GitHubAuthorizations.SingleAsync(); + transaction.Status.Should().Be(GitHubAuthorizationStatus.Failed); + (await secrets.Inner.GetSecretAsync(transaction.PkceVerifierProtected)).Value.Should().Contain("revoked"); + } + + [Fact] + public async Task HumanPredicate_DeniesInternalAndDoesNotUseCallerUserFallback() + { + await using var database = await OpenDatabaseAsync(); + var service = CreateService(database, new InMemorySecretStore(), new StubHttpClientFactory()); + var internalCaller = new CallerContext { User = "entra-looking-user", EntraObjectId = null }; + var internalPrincipal = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("agentweaver_internal", "true")], "test")); + + HumanEntraSubjectAuthorization.Evaluate(internalCaller, internalPrincipal) + .Should().Be(HumanEntraSubjectState.HumanEntraSubjectRequired); + (await service.BeginAsync(internalCaller, internalPrincipal, "settings")).Outcome + .Should().Be(RepoAppAuthorizationOutcome.HumanEntraSubjectRequired); + (await service.PollAsync(internalCaller, internalPrincipal, "opaque")).Outcome + .Should().Be(RepoAppAuthorizationOutcome.HumanEntraSubjectRequired); + } + + [Fact] + public async Task TransactionAndAuditSerialization_DoNotExposeProviderSecrets() + { + await using var database = await OpenDatabaseAsync(); + var secrets = new InMemorySecretStore(); + var service = CreateService(database, secrets, new StubHttpClientFactory(TokenResponse())); + var begin = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + await service.CompleteAsync(Human("entra"), HumanPrincipal(), Query(begin.AuthorizationUrl!, "state"), "code", begin.CallbackCookie); + + var serialized = System.Text.Json.JsonSerializer.Serialize(new + { + transaction = await database.GitHubAuthorizations.SingleAsync(), + audit = await database.GitHubAuditRecords.SingleAsync(), + }); + serialized.Should().NotContain("ghu_").And.NotContain("refresh-original") + .And.NotContain("provider-sensitive-error").And.NotContain("code"); + } + + [Fact] + public async Task ProviderErrorsAreClosedAndCallbackCookieUsesRequiredAttributes() + { + await using var database = await OpenDatabaseAsync(); + var service = CreateService( + database, + new InMemorySecretStore(), + new StubHttpClientFactory("""{"error":"provider-sensitive-error"}""")); + var begin = await service.BeginAsync(Human("entra"), HumanPrincipal(), "settings"); + var context = new DefaultHttpContext(); + RepoAppUserAuthorizationService.SetCallbackCookie(context, begin.CallbackCookie!); + + var result = await service.CompleteAsync( + Human("entra"), HumanPrincipal(), Query(begin.AuthorizationUrl!, "state"), "code", begin.CallbackCookie); + result.Outcome.Should().Be(RepoAppAuthorizationOutcome.GitHubBindingUnavailable); + database.ChangeTracker.Clear(); + (await database.GitHubAuthorizations.SingleAsync()).Status.Should().Be(GitHubAuthorizationStatus.Failed); + (await database.GitHubAuditRecords.SingleAsync()).ReasonCode.Should().Be(GitHubAuditReasonCode.TransactionInvalid); + context.Response.Headers.SetCookie.Single().Should() + .Contain("__Host-agentweaver-repo-app-auth=").And.Contain("path=/").And.Contain("samesite=lax").And.Contain("httponly").And.Contain("secure"); + } + + private static RepoAppUserAuthorizationService CreateService( + MemoryDbContext database, + ISecretStore secrets, + IHttpClientFactory factory) => + new( + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Auth:RepoApp:ClientId"] = "repo-client", + ["Auth:RepoApp:ClientSecret"] = "repo-secret", + ["Auth:RepoApp:CallbackUrl"] = "https://agentweaver.test/auth/github/repo-app/callback", + ["Auth:RepoApp:FrontendUrl"] = "https://agentweaver.test", + }).Build(), + new TwoAppPersistenceStore(database), + secrets, + factory); + + private static CallerContext Human(string subject) => new() { User = subject, EntraObjectId = subject }; + private static ClaimsPrincipal HumanPrincipal() => + new(new ClaimsIdentity([new Claim("oid", "entra")], "test")); + + private static GitHubAuthorizationRecord Transaction( + string state, + string id, + string subject, + GitHubAppKind app, + GitHubAuthorizationPurpose purpose, + DateTimeOffset expiry) => new() + { + State = state, + ExternalTransactionId = id, + AppKind = app, + Purpose = purpose, + EntraObjectId = subject, + ExpiresAtUnixMilliseconds = expiry.ToUnixTimeMilliseconds(), + ReturnRouteKey = "settings", + PkceVerifierProtected = "pkce-reference", + CallbackCookieHash = Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes("cookie"))), + Status = GitHubAuthorizationStatus.Pending, + CreatedAt = DateTimeOffset.UtcNow, + }; + + private static async Task OpenDatabaseAsync() + { + var connection = await OpenConnectionAsync(); + var database = new MemoryDbContext(Options(connection)); + await database.Database.EnsureCreatedAsync(); + return database; + } + + private static async Task OpenConnectionAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + return connection; + } + + private static DbContextOptions Options(SqliteConnection connection) => + new DbContextOptionsBuilder().UseSqlite(connection).Options; + + private static string Query(string url, string name) => + new Uri(url).Query.TrimStart('?').Split('&') + .Select(p => p.Split('=', 2)) + .Single(p => p[0] == name) is var pair + ? Uri.UnescapeDataString(pair[1]) + : throw new InvalidOperationException(); + + private static string TokenResponse( + string accessToken = "ghu_access", + string refreshToken = "refresh-original") => + $$"""{"access_token":"{{accessToken}}","refresh_token":"{{refreshToken}}","expires_in":3600,"error":null}"""; + + private sealed class StubHttpClientFactory(params string[] responses) : IHttpClientFactory + { + private readonly Queue _responses = new(responses.Length == 0 ? [TokenResponse()] : responses); + public List RequestBodies { get; } = []; + + public HttpClient CreateClient(string name) => new(new StubHandler(RequestBodies, _responses)); + } + + private sealed class StubHandler(List bodies, Queue responses) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + bodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(ct)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responses.Count > 0 ? responses.Dequeue() : TokenResponse()), + }; + } + } + + private sealed class ThrowingGetSecretStore : ISecretStore + { + public InMemorySecretStore Inner { get; } = new(); + public Task GetSecretAsync(string key, CancellationToken ct = default) => + throw new InvalidOperationException("storage failure"); + public Task SetSecretAsync(string key, string value, string? etag = null, CancellationToken ct = default) => + Inner.SetSecretAsync(key, value, etag, ct); + public Task DeleteSecretAsync(string key, CancellationToken ct = default) => Inner.DeleteSecretAsync(key, ct); + } +}