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);
+ }
+}