Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api-tests
diff options
context:
space:
mode:
authorLeitwolf2026-10-23 08:01:02 +0300
committerGitHub2026-10-23 08:01:02 +0300
commit3481b5fe411fb3aa5898b0965e42cc852f3d690c (patch)
tree4e5f6caf530344fb13f2b892238d1e1b1f89c10c /apps/api-tests
parenta68bb18e2d750fabcbcff985b5fe921d7c828d7e (diff)
parent7f1cb91eb16bc3d7fd78320465c32d5e2f47ec97 (diff)
downloadprogmaweb-3481b5fe411fb3aa5898b0965e42cc852f3d690c.tar.gz
Merge pull request #43 from Progmasoft/feature/progmasoft-credentials
Add Progmasoft OAuth, an OAuth 2.1 and OpenID Connect provider
Diffstat (limited to 'apps/api-tests')
-rw-r--r--apps/api-tests/AccountApiFactory.cs53
-rw-r--r--apps/api-tests/AccountStoreSelectionTests.cs10
-rw-r--r--apps/api-tests/OAuthFlowTests.cs351
-rw-r--r--apps/api-tests/OAuthPolicyTests.cs262
-rw-r--r--apps/api-tests/OAuthTestClient.cs169
-rw-r--r--apps/api-tests/packages.lock.json439
6 files changed, 1281 insertions, 3 deletions
diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs
index 4692f0c..926de65 100644
--- a/apps/api-tests/AccountApiFactory.cs
+++ b/apps/api-tests/AccountApiFactory.cs
@@ -16,12 +16,19 @@ namespace Progmasoft.Progmaweb.Api.Tests;
/// </param>
/// <param name="environment">Name of the hosting environment.</param>
/// <param name="accountStore">Value of <c>Accounts:Store</c>; <see langword="null"/> leaves it unset.</param>
+/// <param name="oauthKeys">
+/// Whether to give Progmasoft OAuth key files. Production refuses to start without them; other environments make
+/// their own keys.
+/// </param>
internal sealed class AccountApiFactory(
bool configureGoogle = true,
string? accountDatabase = null,
string environment = "Testing",
- string? accountStore = null) : WebApplicationFactory<Program>
+ string? accountStore = null,
+ bool oauthKeys = true) : WebApplicationFactory<Program>
{
+ private readonly string keyDirectory = Path.Combine(Path.GetTempPath(), "progmaweb-oauth-keys-" + Guid.NewGuid().ToString("N"));
+
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(environment);
@@ -39,6 +46,29 @@ internal sealed class AccountApiFactory(
configureGoogle ? "test-client.apps.googleusercontent.com" : string.Empty);
builder.UseSetting("Authentication:Google:ClientSecret", configureGoogle ? "test-secret" : string.Empty);
+ // Progmasoft OAuth reads its host while services are registered, like the Google client above. Its own host
+ // keeps the host filter honest: the account API answers on localhost, OAuth on oauth.localhost.
+ builder.UseSetting("OAuth:Host", OAuthHost);
+ builder.UseSetting("AllowedHosts",
+ $"localhost;127.0.0.1;progmasoft.com;account.progmasoft.com;www.progmasoft.com;{OAuthHost}");
+ if (oauthKeys && environment == "Production")
+ {
+ Directory.CreateDirectory(keyDirectory);
+ using System.Security.Cryptography.RSA signing = System.Security.Cryptography.RSA.Create(2048);
+ string signingPath = Path.Combine(keyDirectory, "signing.pem");
+ string encryptionPath = Path.Combine(keyDirectory, "encryption.key");
+ File.WriteAllText(signingPath, signing.ExportPkcs8PrivateKeyPem());
+ File.WriteAllText(encryptionPath,
+ Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)));
+ builder.UseSetting("OAuth:SigningKeyPath", signingPath);
+ builder.UseSetting("OAuth:EncryptionKeyPath", encryptionPath);
+ }
+ else
+ {
+ builder.UseSetting("OAuth:SigningKeyPath", string.Empty);
+ builder.UseSetting("OAuth:EncryptionKeyPath", string.Empty);
+ }
+
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
@@ -49,6 +79,27 @@ internal sealed class AccountApiFactory(
});
}
+ /// <summary>The host Progmasoft OAuth answers on in the tests.</summary>
+ public const string OAuthHost = "oauth.localhost";
+
+ /// <summary>A client of the OAuth host that keeps its cookies and does not follow redirections.</summary>
+ public HttpClient CreateOAuthClient() => CreateClient(
+ new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ BaseAddress = new Uri($"https://{OAuthHost}"),
+ HandleCookies = true
+ });
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (Directory.Exists(keyDirectory))
+ {
+ Directory.Delete(keyDirectory, recursive: true);
+ }
+ }
+
public HttpClient CreateAccountClient(bool handleCookies = true) => CreateClient(
new WebApplicationFactoryClientOptions
{
diff --git a/apps/api-tests/AccountStoreSelectionTests.cs b/apps/api-tests/AccountStoreSelectionTests.cs
index e9724c0..05ed31b 100644
--- a/apps/api-tests/AccountStoreSelectionTests.cs
+++ b/apps/api-tests/AccountStoreSelectionTests.cs
@@ -73,6 +73,16 @@ public sealed class AccountStoreSelectionTests
}
[Fact]
+ public void AProductionServiceWithoutOAuthKeysDoesNotStart()
+ {
+ using AccountApiFactory factory = new(environment: "Production", accountStore: "Memory", oauthKeys: false);
+
+ InvalidOperationException refused = Assert.Throws<InvalidOperationException>(() => factory.CreateAccountClient());
+
+ Assert.Contains("must name the token key files in production", refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
public async Task AProductionServiceThatAsksForMemoryStartsAndKeepsAccountsInMemory()
{
await using AccountApiFactory factory = new(environment: "Production", accountStore: "Memory");
diff --git a/apps/api-tests/OAuthFlowTests.cs b/apps/api-tests/OAuthFlowTests.cs
new file mode 100644
index 0000000..dcd94de
--- /dev/null
+++ b/apps/api-tests/OAuthFlowTests.cs
@@ -0,0 +1,351 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+public sealed class OAuthFlowTests
+{
+ private static CancellationToken Cancel => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task AuthorizationCodeWithPkceIssuesTokensThatCarryOnlyTheGrantedClaims()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder1", "holder@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync();
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid profile email offline_access viget.packages.read",
+ challenge);
+ Assert.StartsWith(OAuthTestClient.RedirectUri, redirect.ToString(), StringComparison.Ordinal);
+ Assert.Equal("state-123", OAuthTestClient.Query(redirect, "state"));
+ string code = OAuthTestClient.Query(redirect, "code")!;
+
+ (HttpStatusCode status, JsonElement tokens) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = code,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ Assert.True(status == HttpStatusCode.OK, tokens.ToString());
+
+ JsonElement identity = OAuthTestClient.Payload(tokens.GetProperty("id_token").GetString()!);
+ Assert.Equal($"https://{AccountApiFactory.OAuthHost}/", identity.GetProperty("iss").GetString());
+ Assert.Equal(clientId, identity.GetProperty("aud").GetString());
+ Assert.Equal("OAuthHolder1", identity.GetProperty("preferred_username").GetString());
+ Assert.Equal("holder@example.com", identity.GetProperty("email").GetString());
+ Assert.False(identity.GetProperty("email_verified").GetBoolean());
+ Assert.Equal("nonce-456", identity.GetProperty("nonce").GetString());
+
+ using HttpClient client = factory.CreateOAuthClient();
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", tokens.GetProperty("access_token").GetString());
+ JsonElement user = await client.GetFromJsonAsync<JsonElement>("/connect/userinfo", Cancel);
+ Assert.Equal(identity.GetProperty("sub").GetString(), user.GetProperty("sub").GetString());
+ Assert.Equal("OAuthHolder1", user.GetProperty("name").GetString());
+ Assert.Equal("holder@example.com", user.GetProperty("email").GetString());
+
+ // The code is single use.
+ (HttpStatusCode reused, _) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = code,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, reused);
+ }
+
+ [Fact]
+ public async Task ScopesThatWereNotGrantedAreNotInTheTokens()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder2", "holder2@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync();
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid", challenge);
+ (_, JsonElement tokens) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = OAuthTestClient.Query(redirect, "code")!,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ JsonElement identity = OAuthTestClient.Payload(tokens.GetProperty("id_token").GetString()!);
+ Assert.False(identity.TryGetProperty("email", out _));
+ Assert.False(identity.TryGetProperty("preferred_username", out _));
+ Assert.False(tokens.TryGetProperty("refresh_token", out _));
+ }
+
+ [Fact]
+ public async Task RefreshTokensRotateAndAReusedOneIsRefused()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder3", "holder3@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync();
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid offline_access", challenge);
+ (_, JsonElement first) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = OAuthTestClient.Query(redirect, "code")!,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ string refresh = first.GetProperty("refresh_token").GetString()!;
+
+ Dictionary<string, string> refreshRequest = new()
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refresh,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ };
+ (HttpStatusCode renewed, JsonElement second) = await browser.TokenAsync(refreshRequest);
+ Assert.Equal(HttpStatusCode.OK, renewed);
+ Assert.NotEqual(refresh, second.GetProperty("refresh_token").GetString());
+
+ (HttpStatusCode reused, JsonElement error) = await browser.TokenAsync(refreshRequest);
+ Assert.Equal(HttpStatusCode.BadRequest, reused);
+ Assert.Equal("invalid_grant", error.GetProperty("error").GetString());
+ }
+
+ [Fact]
+ public async Task AnAllowedApplicationSkipsTheConsentAndWithdrawingEndsItsTokens()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder4", "holder4@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync();
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid offline_access", challenge);
+ (_, JsonElement tokens) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = OAuthTestClient.Query(redirect, "code")!,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+
+ HttpResponseMessage again = await browser.Browser.GetAsync(
+ OAuthTestClient.AuthorizePath(clientId, "openid offline_access", OAuthTestClient.Pkce().Challenge), Cancel);
+ Assert.Equal(HttpStatusCode.Redirect, again.StatusCode);
+ Assert.NotNull(OAuthTestClient.Query(again.Headers.Location!, "code"));
+
+ string allowed = await browser.Browser.GetStringAsync("/account/applications", Cancel);
+ Assert.Contains("Example Client", allowed, StringComparison.Ordinal);
+ string action = System.Text.RegularExpressions.Regex.Match(allowed,
+ "action=\"(/account/applications/[^\"]+/revoke)\"").Groups[1].Value;
+ HttpResponseMessage withdrawn = await browser.SubmitAsync("/account/applications", action, []);
+ Assert.Equal(HttpStatusCode.Redirect, withdrawn.StatusCode);
+
+ (HttpStatusCode refreshed, _) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = tokens.GetProperty("refresh_token").GetString()!,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, refreshed);
+ }
+
+ [Fact]
+ public async Task DenyingTheConsentReturnsAccessDenied()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder5", "holder5@example.com");
+ (string clientId, _) = await browser.RegisterApplicationAsync();
+
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid email", OAuthTestClient.Pkce().Challenge, "deny");
+ Assert.Equal("access_denied", OAuthTestClient.Query(redirect, "error"));
+ Assert.Null(OAuthTestClient.Query(redirect, "code"));
+ }
+
+ [Fact]
+ public async Task TheConsentPageListsEachRequestedGrant()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder6", "holder6@example.com");
+ (string clientId, _) = await browser.RegisterApplicationAsync(
+ "profile email viget.packages.read viget.packages.publish viget.packages.yank");
+
+ HttpResponseMessage page = await browser.Browser.GetAsync(OAuthTestClient.AuthorizePath(clientId,
+ "openid profile email viget.packages.read viget.packages.publish viget.packages.yank",
+ OAuthTestClient.Pkce().Challenge), Cancel);
+ string html = await page.Content.ReadAsStringAsync(Cancel);
+ Assert.Contains("Read your Progmasoft account name.", html, StringComparison.Ordinal);
+ Assert.Contains("Read the email address you sign in and registered with.", html, StringComparison.Ordinal);
+ Assert.Contains("Read the packages you have published on ViGet.", html, StringComparison.Ordinal);
+ Assert.Contains("Publish packages to ViGet under your account.", html, StringComparison.Ordinal);
+ Assert.Contains("Yank packages you have published on ViGet.", html, StringComparison.Ordinal);
+ Assert.Contains("registered by <strong>OAuthHolder6</strong>", html, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ViGetScopesGiveAccessTokensForViGet()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder7", "holder7@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync("viget.packages.publish");
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid viget.packages.publish", challenge);
+ (HttpStatusCode status, JsonElement tokens) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = OAuthTestClient.Query(redirect, "code")!,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ Assert.Equal(HttpStatusCode.OK, status);
+ Assert.Equal("openid viget.packages.publish", tokens.GetProperty("scope").GetString());
+
+ // The access token is encrypted for the resource servers; introspection shows its audience.
+ using HttpClient client = factory.CreateOAuthClient();
+ HttpResponseMessage response = await client.PostAsync("/connect/introspect", new FormUrlEncodedContent(
+ new Dictionary<string, string>
+ {
+ ["token"] = tokens.GetProperty("access_token").GetString()!,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ }), Cancel);
+ JsonElement introspection = await response.Content.ReadFromJsonAsync<JsonElement>(Cancel);
+ Assert.True(introspection.GetProperty("active").GetBoolean(), introspection.ToString());
+ Assert.Contains("viget", introspection.GetProperty("aud").ToString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task APublicClientUsesPkceWithoutASecret()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder8", "holder8@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync("profile", isPublic: true,
+ redirectUris: "com.example.app:/callback");
+ Assert.Null(secret);
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+
+ HttpResponseMessage page = await browser.Browser.GetAsync(OAuthTestClient.AuthorizePath(clientId,
+ "openid profile", challenge, "com.example.app:/callback"), Cancel);
+ string html = await page.Content.ReadAsStringAsync(Cancel);
+ List<KeyValuePair<string, string>> fields = OAuthTestClient.HiddenFields(html);
+ fields.Add(new("decision", "allow"));
+ HttpResponseMessage decided = await browser.Browser.PostAsync("/connect/authorize",
+ new FormUrlEncodedContent(fields), Cancel);
+ Uri redirect = decided.Headers.Location!;
+ Assert.Equal("com.example.app", redirect.Scheme);
+
+ (HttpStatusCode status, _) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = OAuthTestClient.Query(redirect, "code")!,
+ ["redirect_uri"] = "com.example.app:/callback",
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId
+ });
+ Assert.Equal(HttpStatusCode.OK, status);
+ }
+
+ [Fact]
+ public async Task OnPostgreSqlTheTablesAreCreatedAndAReusedRefreshTokenRevokesItsChain()
+ {
+ await using PostgresTestDatabase database = await PostgresTestDatabase.CreateAsync();
+ await using AccountApiFactory factory = new(accountDatabase: database.ConnectionString);
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("PostgresHolder1", "postgres1@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync();
+ (string verifier, string challenge) = OAuthTestClient.Pkce();
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid profile offline_access", challenge);
+ (_, JsonElement first) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = OAuthTestClient.Query(redirect, "code")!,
+ ["redirect_uri"] = OAuthTestClient.RedirectUri,
+ ["code_verifier"] = verifier,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ Dictionary<string, string> Refresh(string token) => new()
+ {
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = token,
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ };
+
+ (HttpStatusCode renewed, JsonElement second) =
+ await browser.TokenAsync(Refresh(first.GetProperty("refresh_token").GetString()!));
+ Assert.Equal(HttpStatusCode.OK, renewed);
+
+ // Reusing the first refresh token is taken for theft: the token issued from it stops working as well.
+ (HttpStatusCode reused, _) = await browser.TokenAsync(Refresh(first.GetProperty("refresh_token").GetString()!));
+ (HttpStatusCode chained, _) = await browser.TokenAsync(Refresh(second.GetProperty("refresh_token").GetString()!));
+ Assert.Equal(HttpStatusCode.BadRequest, reused);
+ Assert.Equal(HttpStatusCode.BadRequest, chained);
+
+ await using Npgsql.NpgsqlDataSource dataSource = database.OpenDataSource();
+ await using Npgsql.NpgsqlCommand version =
+ dataSource.CreateCommand("SELECT max(version) FROM oauth_schema_versions");
+ Assert.Equal(Progmasoft.OAuth.Storage.OAuthSchema.CurrentVersion,
+ (int)(await version.ExecuteScalarAsync(Cancel))!);
+ }
+
+ [Fact]
+ public async Task ClientCredentialsActAsTheApplicationAndTakeNoAccountScope()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder9", "holder9@example.com");
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync("profile", clientCredentials: true);
+
+ (HttpStatusCode status, JsonElement token) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!
+ });
+ Assert.True(status == HttpStatusCode.OK, token.ToString());
+
+ (HttpStatusCode scoped, JsonElement error) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = clientId,
+ ["client_secret"] = secret!,
+ ["scope"] = "profile"
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, scoped);
+ Assert.Equal("invalid_scope", error.GetProperty("error").GetString());
+
+ (HttpStatusCode wrongSecret, _) = await browser.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = clientId,
+ ["client_secret"] = "not-the-secret"
+ });
+ Assert.Equal(HttpStatusCode.Unauthorized, wrongSecret);
+ }
+}
diff --git a/apps/api-tests/OAuthPolicyTests.cs b/apps/api-tests/OAuthPolicyTests.cs
new file mode 100644
index 0000000..1f84c77
--- /dev/null
+++ b/apps/api-tests/OAuthPolicyTests.cs
@@ -0,0 +1,262 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Progmasoft.OAuth.Developers;
+using Progmasoft.OAuth.Pages;
+using Progmasoft.OAuth.Storage;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+public sealed class OAuthPolicyTests
+{
+ private static CancellationToken Cancel => TestContext.Current.CancellationToken;
+
+ [Theory]
+ [InlineData("https://client.example/callback", false, true)]
+ [InlineData("http://127.0.0.1:8400/callback", false, true)]
+ [InlineData("http://[::1]:8400/callback", false, true)]
+ [InlineData("http://localhost:3000/callback", false, true)]
+ [InlineData("http://client.example/callback", false, false)]
+ [InlineData("https://client.example/callback#fragment", false, false)]
+ [InlineData("https://user:password@client.example/callback", false, false)]
+ [InlineData("https://*.client.example/callback", false, false)]
+ [InlineData("com.example.app:/callback", true, true)]
+ [InlineData("com.example.app:/callback", false, false)]
+ [InlineData("javascript:alert(1)", true, false)]
+ [InlineData("data:text/html,x", true, false)]
+ [InlineData("myapp:/callback", true, false)]
+ [InlineData("/relative/callback", false, false)]
+ public void RedirectionAddressesFollowRfc8252(string address, bool isPublic, bool accepted) =>
+ Assert.Equal(accepted, ApplicationPolicy.IsRedirectionAddress(address, isPublic));
+
+ [Fact]
+ public void ARegistrationIsCheckedFieldByField()
+ {
+ Dictionary<string, string> errors = ApplicationPolicy.Validate(new ApplicationInput(
+ "x", "http://plain.example", true, [], [], ["profile", "not-a-scope"], true));
+ Assert.Equal(["allowClientCredentials", "displayName", "homepage", "redirectUris", "scopes"],
+ errors.Keys.Order(StringComparer.Ordinal));
+ Assert.Empty(ApplicationPolicy.Validate(new ApplicationInput(
+ "Example", string.Empty, false, ["https://client.example/callback"], [], ["profile"], true)));
+ }
+
+ [Fact]
+ public async Task TheDiscoveryDocumentOffersOnlyOAuth21Flows()
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient client = factory.CreateOAuthClient();
+ JsonElement document = await client.GetFromJsonAsync<JsonElement>("/.well-known/openid-configuration", Cancel);
+
+ Assert.Equal($"https://{AccountApiFactory.OAuthHost}/", document.GetProperty("issuer").GetString());
+ string grants = document.GetProperty("grant_types_supported").ToString();
+ Assert.Contains("authorization_code", grants, StringComparison.Ordinal);
+ Assert.Contains("refresh_token", grants, StringComparison.Ordinal);
+ Assert.DoesNotContain("implicit", grants, StringComparison.Ordinal);
+ Assert.DoesNotContain("password", grants, StringComparison.Ordinal);
+ Assert.Equal(["code"], document.GetProperty("response_types_supported").EnumerateArray()
+ .Select(value => value.GetString()));
+ Assert.Equal(["S256"], document.GetProperty("code_challenge_methods_supported").EnumerateArray()
+ .Select(value => value.GetString()));
+ string scopes = document.GetProperty("scopes_supported").ToString();
+ foreach (string scope in new[]
+ {
+ "openid", "profile", "email", "offline_access", "viget.packages.read", "viget.packages.publish",
+ "viget.packages.yank"
+ })
+ {
+ Assert.Contains($"\"{scope}\"", scopes, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public async Task OAuthAnswersOnlyOnItsHostAndTheAccountApiNotThere()
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient accounts = factory.CreateAccountClient();
+ using HttpClient oauth = factory.CreateOAuthClient();
+
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await accounts.GetAsync("/.well-known/openid-configuration", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await accounts.GetAsync("/signin", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await accounts.PostAsync("/connect/token", null, Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/api/v1/status", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.OK, (await oauth.GetAsync("/health", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.OK, (await accounts.GetAsync("/api/v1/status", Cancel)).StatusCode);
+ }
+
+ [Fact]
+ public async Task AuthorizationRequestsWithoutPkceOrWithAnotherFlowAreRefused()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("PolicyHolder1", "policy1@example.com");
+ (string clientId, _) = await browser.RegisterApplicationAsync("profile");
+
+ // OpenIddict refuses some requests before it trusts the redirection address; those are answered here with
+ // 400 instead of being sent back to the client. Either way, no code is issued.
+ static async Task AssertRefusedAsync(HttpResponseMessage response, string error)
+ {
+ string answer = response.Headers.Location?.ToString()
+ ?? await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found
+ or HttpStatusCode.BadRequest, answer);
+ Assert.DoesNotContain("code=", answer, StringComparison.Ordinal);
+ if (response.Headers.Location is { } location)
+ {
+ Assert.Equal(error, OAuthTestClient.Query(location, "error"));
+ }
+ }
+
+ await AssertRefusedAsync(await browser.Browser.GetAsync(
+ OAuthTestClient.AuthorizePath(clientId, "openid", null), Cancel), "invalid_request");
+ await AssertRefusedAsync(await browser.Browser.GetAsync(OAuthTestClient.AuthorizePath(clientId,
+ "openid", OAuthTestClient.Pkce().Challenge, responseType: "token"), Cancel), "unsupported_response_type");
+ await AssertRefusedAsync(await browser.Browser.GetAsync(OAuthTestClient.AuthorizePath(clientId,
+ "openid viget.packages.publish", OAuthTestClient.Pkce().Challenge), Cancel), "invalid_scope");
+
+ // An address that was not registered never receives a redirection.
+ HttpResponseMessage unknownAddress = await browser.Browser.GetAsync(OAuthTestClient.AuthorizePath(clientId,
+ "openid", OAuthTestClient.Pkce().Challenge, "https://attacker.example/callback"), Cancel);
+ Assert.Equal(HttpStatusCode.BadRequest, unknownAddress.StatusCode);
+ Assert.Null(unknownAddress.Headers.Location);
+ }
+
+ [Fact]
+ public async Task AnAccountThatIsNotSignedInIsSentToTheSignInPageAndBack()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient developer = new(factory);
+ await developer.RegisterAndSignInAsync("PolicyHolder2", "policy2@example.com");
+ (string clientId, _) = await developer.RegisterApplicationAsync("profile");
+
+ using HttpClient anonymous = factory.CreateOAuthClient();
+ string path = OAuthTestClient.AuthorizePath(clientId, "openid", OAuthTestClient.Pkce().Challenge, prompt: "login");
+ HttpResponseMessage response = await anonymous.GetAsync(path, Cancel);
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ string location = response.Headers.Location!.ToString();
+ Assert.Contains("/signin?ReturnUrl=", location, StringComparison.Ordinal);
+ Assert.Contains("connect%2Fauthorize", location, StringComparison.Ordinal);
+ Assert.DoesNotContain("prompt", Uri.UnescapeDataString(location), StringComparison.Ordinal);
+
+ HttpResponseMessage none = await anonymous.GetAsync(OAuthTestClient.AuthorizePath(clientId, "openid",
+ OAuthTestClient.Pkce().Challenge, prompt: "none"), Cancel);
+ Assert.Equal("login_required", OAuthTestClient.Query(none.Headers.Location!, "error"));
+ }
+
+ [Fact]
+ public async Task TheSignInPageGivesOneMessageForEveryFailureAndKeepsReturnAddressesLocal()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ HttpResponseMessage failed = await browser.SubmitAsync("/signin", "/signin", new Dictionary<string, string>
+ {
+ ["email"] = "nobody@example.com",
+ ["password"] = "wrong password here",
+ ["returnUrl"] = "https://attacker.example/"
+ });
+ Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode);
+ string html = await failed.Content.ReadAsStringAsync(Cancel);
+ Assert.Contains("The email address or the password is not right.", html, StringComparison.Ordinal);
+ Assert.Contains("name=\"returnUrl\" value=\"/\"", html, StringComparison.Ordinal);
+
+ HttpResponseMessage forged = await browser.Browser.PostAsync("/signin", new FormUrlEncodedContent(
+ new Dictionary<string, string> { ["email"] = "a@example.com", ["password"] = "x" }), Cancel);
+ Assert.Equal(HttpStatusCode.BadRequest, forged.StatusCode);
+
+ Assert.Equal("/", OAuthAccount.LocalReturnUrl("//attacker.example"));
+ Assert.Equal("/", OAuthAccount.LocalReturnUrl("/\\attacker.example"));
+ Assert.Equal("/developers", OAuthAccount.LocalReturnUrl("/developers"));
+ }
+
+ [Fact]
+ public async Task ThePortalRefusesBadAddressesAndHidesOtherAccountsApplications()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient owner = new(factory);
+ await owner.RegisterAndSignInAsync("PolicyOwner1", "owner1@example.com");
+ (string clientId, _) = await owner.RegisterApplicationAsync("profile");
+
+ HttpResponseMessage refused = await owner.SubmitAsync("/developers/new", "/developers/new",
+ new Dictionary<string, string>
+ {
+ ["displayName"] = "Plain HTTP",
+ ["clientType"] = "confidential",
+ ["redirectUris"] = "http://plain.example/callback"
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
+ Assert.Contains("cannot be registered", await refused.Content.ReadAsStringAsync(Cancel),
+ StringComparison.Ordinal);
+
+ OAuthTestClient stranger = new(factory);
+ await stranger.RegisterAndSignInAsync("PolicyStranger1", "stranger1@example.com");
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await stranger.Browser.GetAsync($"/developers/{clientId}", Cancel)).StatusCode);
+ HttpResponseMessage deleted = await stranger.SubmitAsync("/", $"/developers/{clientId}/delete", []);
+ Assert.Equal(HttpStatusCode.NotFound, deleted.StatusCode);
+ Assert.Equal(HttpStatusCode.OK, (await owner.Browser.GetAsync($"/developers/{clientId}", Cancel)).StatusCode);
+
+ HttpResponseMessage ownDelete = await owner.SubmitAsync($"/developers/{clientId}",
+ $"/developers/{clientId}/delete", []);
+ Assert.Equal(HttpStatusCode.Redirect, ownDelete.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await owner.Browser.GetAsync($"/developers/{clientId}", Cancel)).StatusCode);
+ }
+
+ [Fact]
+ public async Task RotatingTheSecretRetiresTheOldOne()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient owner = new(factory);
+ await owner.RegisterAndSignInAsync("PolicyOwner2", "owner2@example.com");
+ (string clientId, string? secret) = await owner.RegisterApplicationAsync("profile", clientCredentials: true);
+
+ HttpResponseMessage rotated = await owner.SubmitAsync($"/developers/{clientId}",
+ $"/developers/{clientId}/secret", []);
+ string html = await rotated.Content.ReadAsStringAsync(Cancel);
+ string newSecret = System.Web.HttpUtility.HtmlDecode(
+ System.Text.RegularExpressions.Regex.Matches(html, "<code>([^<]*)</code>")[1].Groups[1].Value);
+ Assert.NotEqual(secret, newSecret);
+
+ (HttpStatusCode old, _) = await owner.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_secret"] = secret!
+ });
+ (HttpStatusCode current, _) = await owner.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_secret"] = newSecret
+ });
+ Assert.Equal(HttpStatusCode.Unauthorized, old);
+ Assert.Equal(HttpStatusCode.OK, current);
+ }
+
+ [Fact]
+ public async Task PagesAllowTh