diff options
| author | Leitwolf | 2026-10-23 08:01:02 +0300 |
|---|---|---|
| committer | GitHub | 2026-10-23 08:01:02 +0300 |
| commit | 3481b5fe411fb3aa5898b0965e42cc852f3d690c (patch) | |
| tree | 4e5f6caf530344fb13f2b892238d1e1b1f89c10c /apps/api-tests | |
| parent | a68bb18e2d750fabcbcff985b5fe921d7c828d7e (diff) | |
| parent | 7f1cb91eb16bc3d7fd78320465c32d5e2f47ec97 (diff) | |
| download | progmaweb-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.cs | 53 | ||||
| -rw-r--r-- | apps/api-tests/AccountStoreSelectionTests.cs | 10 | ||||
| -rw-r--r-- | apps/api-tests/OAuthFlowTests.cs | 351 | ||||
| -rw-r--r-- | apps/api-tests/OAuthPolicyTests.cs | 262 | ||||
| -rw-r--r-- | apps/api-tests/OAuthTestClient.cs | 169 | ||||
| -rw-r--r-- | apps/api-tests/packages.lock.json | 439 |
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 |