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

summaryrefslogtreecommitdiffhomepage
path: root/apps/api-tests
diff options
context:
space:
mode:
authorLeitwolf112026-10-23 11:02:39 +0300
committerLeitwolf112026-10-23 11:02:39 +0300
commit9099e9fbb50d27b622362b0c2f0dda5085d1fa03 (patch)
tree7d4905828cf0ff34c446025529f3adafa797a86f /apps/api-tests
parent3481b5fe411fb3aa5898b0965e42cc852f3d690c (diff)
downloadprogmaweb-9099e9fbb50d27b622362b0c2f0dda5085d1fa03.tar.gz
Move the OAuth pages to Next.js and split the authorize host and the console
Progmasoft OAuth now runs on three hosts: - credentials.progmasoft.com stays the issuer: discovery documents, key set, token, user information, revocation and introspection. - authorize.progmasoft.com serves the pages people see: the authorization and end session endpoints (registered with OpenIddict as absolute URIs on that host), sign-in, the consent screen and the applications an account has allowed. - console.progmasoft.com is the Progmasoft Console. Its overview links the areas; OAuth 2.1 tokens live under /credentials, with New token, then the application type, then its form. The console has no sign-in of its own and never redirects. It uses the Progmasoft account session, so production now shares that cookie with progmasoft.com (__Secure-ProgmasoftSession, Domain=progmasoft.com, HttpOnly, Secure, SameSite=Lax). Without AccountSessions:CookieDomain the cookie stays host-only with the __Host- name, as in development and tests. Application types replace the confidential/public switch: web, single-page, desktop, mobile and service. Each type has its redirection rules (RFC 8252), its secret or none, and its grants; only a service uses client credentials and it registers no address or scope. The type is kept as an application property and cannot change. The HTML pages of the API are gone. The pages are Next.js pages in four languages (en-US, de-DE, ru-RU, he-IL) that call JSON endpoints under /api/v1/oauth. Requests that change state must come from the page's own origin; antiforgery forms are no longer needed. The token, revocation and user information endpoints answer CORS requests from the origins of single-page applications, without credentials; the discovery documents and the key set answer every origin. The host filter keeps every path on its host. Nginx gains the authorize and console virtual hosts, and the docs describe the hosts, the types and the shared session.
Diffstat (limited to 'apps/api-tests')
-rw-r--r--apps/api-tests/AccountApiFactory.cs41
-rw-r--r--apps/api-tests/OAuthFlowTests.cs95
-rw-r--r--apps/api-tests/OAuthPolicyTests.cs320
-rw-r--r--apps/api-tests/OAuthTestClient.cs171
4 files changed, 460 insertions, 167 deletions
diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs
index 926de65..bbe2d64 100644
--- a/apps/api-tests/AccountApiFactory.cs
+++ b/apps/api-tests/AccountApiFactory.cs
@@ -49,8 +49,10 @@ internal sealed class AccountApiFactory(
// 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("OAuth:AuthorizeHost", AuthorizeHost);
+ builder.UseSetting("OAuth:ConsoleHost", ConsoleHost);
builder.UseSetting("AllowedHosts",
- $"localhost;127.0.0.1;progmasoft.com;account.progmasoft.com;www.progmasoft.com;{OAuthHost}");
+ $"localhost;127.0.0.1;progmasoft.com;account.progmasoft.com;www.progmasoft.com;{OAuthHost};{AuthorizeHost};{ConsoleHost}");
if (oauthKeys && environment == "Production")
{
Directory.CreateDirectory(keyDirectory);
@@ -74,7 +76,9 @@ internal sealed class AccountApiFactory(
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["AccountSessions:LifetimeHours"] = "2",
- ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession"
+ ["AccountSessions:CookieName"] = "__Host-ProgmasoftSession",
+ // A host-only cookie: the console tests send the account session themselves.
+ ["AccountSessions:CookieDomain"] = string.Empty
});
});
}
@@ -82,6 +86,10 @@ internal sealed class AccountApiFactory(
/// <summary>The host Progmasoft OAuth answers on in the tests.</summary>
public const string OAuthHost = "oauth.localhost";
+ public const string AuthorizeHost = "authorize.localhost";
+
+ public const string ConsoleHost = "console.localhost";
+
/// <summary>A client of the OAuth host that keeps its cookies and does not follow redirections.</summary>
public HttpClient CreateOAuthClient() => CreateClient(
new WebApplicationFactoryClientOptions
@@ -91,6 +99,35 @@ internal sealed class AccountApiFactory(
HandleCookies = true
});
+ /// <summary>A browser on the authorize host: sign-in, consent and the allowed applications.</summary>
+ public HttpClient CreateAuthorizeClient() => CreateClient(
+ new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ BaseAddress = new Uri($"https://{AuthorizeHost}"),
+ HandleCookies = true
+ });
+
+ /// <summary>
+ /// A browser on the Progmasoft Console. Production shares the account session cookie with progmasoft.com; the
+ /// test hosts are not under one domain, so a test sends the session cookie of an account itself.
+ /// </summary>
+ public HttpClient CreateConsoleClient(string? sessionCookie = null)
+ {
+ HttpClient client = CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ BaseAddress = new Uri($"https://{ConsoleHost}"),
+ HandleCookies = false
+ });
+ if (sessionCookie is not null)
+ {
+ client.DefaultRequestHeaders.Add("Cookie", sessionCookie);
+ }
+
+ return client;
+ }
+
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
diff --git a/apps/api-tests/OAuthFlowTests.cs b/apps/api-tests/OAuthFlowTests.cs
index dcd94de..b550e0e 100644
--- a/apps/api-tests/OAuthFlowTests.cs
+++ b/apps/api-tests/OAuthFlowTests.cs
@@ -152,12 +152,17 @@ public sealed class OAuthFlowTests
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);
+ JsonElement allowed = await (await browser.SendAsync(HttpMethod.Get, "/api/v1/oauth/allowed"))
+ .Content.ReadFromJsonAsync<JsonElement>(Cancel);
+ JsonElement entry = Assert.Single(allowed.EnumerateArray());
+ Assert.Equal("Example Client", entry.GetProperty("displayName").GetString());
+ string authorizationId = entry.GetProperty("authorizationId").GetString()!;
+ HttpResponseMessage foreign = await browser.SendAsync(HttpMethod.Delete,
+ $"/api/v1/oauth/allowed/{authorizationId}", origin: "https://attacker.example");
+ Assert.Equal(HttpStatusCode.Forbidden, foreign.StatusCode);
+ HttpResponseMessage withdrawn = await browser.SendAsync(HttpMethod.Delete,
+ $"/api/v1/oauth/allowed/{authorizationId}");
+ Assert.Equal(HttpStatusCode.NoContent, withdrawn.StatusCode);
(HttpStatusCode refreshed, _) = await browser.TokenAsync(new Dictionary<string, string>
{
@@ -191,16 +196,60 @@ public sealed class OAuthFlowTests
(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,
+ HttpResponseMessage authorize = 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);
+ Uri consentPage = authorize.Headers.Location!;
+ Assert.True(OAuthTestClient.IsConsentPage(consentPage), consentPage.OriginalString);
+
+ JsonElement consent = await browser.Browser.GetFromJsonAsync<JsonElement>(
+ "/api/v1/oauth/consent" + consentPage.OriginalString["/consent".Length..], Cancel);
+ Assert.Equal(["profile", "email", "viget.packages.read", "viget.packages.publish", "viget.packages.yank"],
+ consent.GetProperty("scopes").EnumerateArray().Select(scope => scope.GetString()));
+ Assert.Equal("Example Client", consent.GetProperty("displayName").GetString());
+ Assert.Equal("OAuthHolder6", consent.GetProperty("owner").GetString());
+ Assert.Equal("OAuthHolder6", consent.GetProperty("accountName").GetString());
+ Assert.Equal("https://client.example", consent.GetProperty("returnTo").GetString());
+ Assert.Equal("https://client.example", consent.GetProperty("homepage").GetString());
+ }
+
+ [Fact]
+ public async Task TheConsentPageDescribesOnlyRequestsTheApplicationCouldMake()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder10", "holder10@example.com");
+ (string clientId, _) = await browser.RegisterApplicationAsync("profile");
+
+ string Query(string redirectUri, string scope) =>
+ $"/api/v1/oauth/consent?client_id={clientId}&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
+ $"&scope={Uri.EscapeDataString(scope)}";
+ Assert.Equal(HttpStatusCode.OK,
+ (await browser.Browser.GetAsync(Query(OAuthTestClient.RedirectUri, "openid profile"), Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest,
+ (await browser.Browser.GetAsync(Query("https://attacker.example/callback", "openid"), Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest,
+ (await browser.Browser.GetAsync(Query(OAuthTestClient.RedirectUri, "openid email"), Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, (await browser.Browser.GetAsync(
+ "/api/v1/oauth/consent?client_id=pmo_missing&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback",
+ Cancel)).StatusCode);
+ }
+
+ [Fact]
+ public async Task AConsentDecisionFromAnotherSiteIsRefused()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient browser = new(factory);
+ await browser.RegisterAndSignInAsync("OAuthHolder11", "holder11@example.com");
+ (string clientId, _) = await browser.RegisterApplicationAsync("profile");
+
+ HttpResponseMessage authorize = await browser.Browser.GetAsync(
+ OAuthTestClient.AuthorizePath(clientId, "openid profile", OAuthTestClient.Pkce().Challenge), Cancel);
+ Uri consentPage = authorize.Headers.Location!;
+ HttpResponseMessage forged = await browser.DecideAsync(consentPage, "allow", "https://attacker.example");
+ Assert.Equal(HttpStatusCode.Forbidden, forged.StatusCode);
+ HttpResponseMessage blind = await browser.DecideAsync(consentPage, "allow", origin: null);
+ Assert.Equal(HttpStatusCode.Forbidden, blind.StatusCode);
}
[Fact]
@@ -244,19 +293,13 @@ public sealed class OAuthFlowTests
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,
+ (string clientId, string? secret) = await browser.RegisterApplicationAsync("profile", kind: "desktop",
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!;
+ Uri redirect = await browser.AuthorizeAsync(clientId, "openid profile", challenge,
+ redirectUri: "com.example.app:/callback");
Assert.Equal("com.example.app", redirect.Scheme);
(HttpStatusCode status, _) = await browser.TokenAsync(new Dictionary<string, string>
@@ -320,7 +363,7 @@ public sealed class OAuthFlowTests
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);
+ (string clientId, string? secret) = await browser.RegisterServiceAsync();
(HttpStatusCode status, JsonElement token) = await browser.TokenAsync(new Dictionary<string, string>
{
@@ -337,8 +380,10 @@ public sealed class OAuthFlowTests
["client_secret"] = secret!,
["scope"] = "profile"
});
+ // A service has no scope permission at all, so OpenIddict refuses the scope before the handler sees it.
Assert.Equal(HttpStatusCode.BadRequest, scoped);
- Assert.Equal("invalid_scope", error.GetProperty("error").GetString());
+ Assert.Equal("invalid_request", error.GetProperty("error").GetString());
+ Assert.Contains("scope", error.GetProperty("error_description").GetString(), StringComparison.Ordinal);
(HttpStatusCode wrongSecret, _) = await browser.TokenAsync(new Dictionary<string, string>
{
diff --git a/apps/api-tests/OAuthPolicyTests.cs b/apps/api-tests/OAuthPolicyTests.cs
index 1f84c77..2c95656 100644
--- a/apps/api-tests/OAuthPolicyTests.cs
+++ b/apps/api-tests/OAuthPolicyTests.cs
@@ -8,7 +8,6 @@ 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;
@@ -18,32 +17,64 @@ 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));
+ [InlineData("https://client.example/callback", "web", true)]
+ [InlineData("http://127.0.0.1:8400/callback", "web", true)]
+ [InlineData("http://[::1]:8400/callback", "web", true)]
+ [InlineData("http://localhost:3000/callback", "web", true)]
+ [InlineData("http://client.example/callback", "web", false)]
+ [InlineData("https://client.example/callback#fragment", "web", false)]
+ [InlineData("https://user:password@client.example/callback", "web", false)]
+ [InlineData("https://*.client.example/callback", "web", false)]
+ [InlineData("com.example.app:/callback", "web", false)]
+ [InlineData("/relative/callback", "web", false)]
+ [InlineData("https://app.example/callback", "spa", true)]
+ [InlineData("http://localhost:5173/callback", "spa", true)]
+ [InlineData("com.example.app:/callback", "spa", false)]
+ [InlineData("http://127.0.0.1:8400/callback", "desktop", true)]
+ [InlineData("com.example.app:/callback", "desktop", true)]
+ [InlineData("https://client.example/callback", "desktop", false)]
+ [InlineData("myapp:/callback", "desktop", false)]
+ [InlineData("javascript:alert(1)", "desktop", false)]
+ [InlineData("data:text/html,x", "mobile", false)]
+ [InlineData("com.example.app:/callback", "mobile", true)]
+ [InlineData("https://app.example/open", "mobile", true)]
+ [InlineData("http://127.0.0.1:8400/callback", "mobile", false)]
+ [InlineData("https://client.example/callback", "service", false)]
+ public void RedirectionAddressesFollowRfc8252ForEachType(string address, string kind, bool accepted) =>
+ Assert.Equal(accepted, ApplicationPolicy.IsRedirectionAddress(address, ApplicationKinds.Parse(kind)!.Value));
[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));
+ "x", "http://plain.example", ApplicationKind.Desktop, [], [], ["profile", "not-a-scope"]));
+ Assert.Equal(["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)));
+ "Example", string.Empty, ApplicationKind.Web, ["https://client.example/callback"], [], ["profile"])));
+ }
+
+ [Fact]
+ public void AServiceRegistersNeitherAddressesNorAccountScopes()
+ {
+ Assert.Empty(ApplicationPolicy.Validate(new ApplicationInput(
+ "Build robot", string.Empty, ApplicationKind.Service, [], [], [])));
+ Dictionary<string, string> errors = ApplicationPolicy.Validate(new ApplicationInput(
+ "Build robot", string.Empty, ApplicationKind.Service, ["https://client.example/callback"], [], ["profile"]));
+ Assert.Equal(["redirectUris", "scopes"], errors.Keys.Order(StringComparer.Ordinal));
+ }
+
+ [Fact]
+ public void EveryTypeHasANameThatParsesBack()
+ {
+ Assert.Equal(["web", "spa", "desktop", "mobile", "service"], ApplicationKinds.All.Select(kind => kind.Slug()));
+ foreach (ApplicationKind kind in ApplicationKinds.All)
+ {
+ Assert.Equal(kind, ApplicationKinds.Parse(kind.Slug()));
+ }
+
+ Assert.Null(ApplicationKinds.Parse("confidential"));
+ Assert.Equal([ApplicationKind.SinglePage, ApplicationKind.Desktop, ApplicationKind.Mobile],
+ ApplicationKinds.All.Where(kind => kind.IsPublic()));
}
[Fact]
@@ -54,6 +85,13 @@ public sealed class OAuthPolicyTests
JsonElement document = await client.GetFromJsonAsync<JsonElement>("/.well-known/openid-configuration", Cancel);
Assert.Equal($"https://{AccountApiFactory.OAuthHost}/", document.GetProperty("issuer").GetString());
+ // People visit the authorize host; clients call the issuer.
+ Assert.Equal($"https://{AccountApiFactory.AuthorizeHost}/connect/authorize",
+ document.GetProperty("authorization_endpoint").GetString());
+ Assert.Equal($"https://{AccountApiFactory.AuthorizeHost}/connect/logout",
+ document.GetProperty("end_session_endpoint").GetString());
+ Assert.Equal($"https://{AccountApiFactory.OAuthHost}/connect/token",
+ document.GetProperty("token_endpoint").GetString());
string grants = document.GetProperty("grant_types_supported").ToString();
Assert.Contains("authorization_code", grants, StringComparison.Ordinal);
Assert.Contains("refresh_token", grants, StringComparison.Ordinal);
@@ -83,7 +121,42 @@ public sealed class OAuthPolicyTests
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.GetAsync("/api/v1/oauth/session", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/api/v1/accounts/me", Cancel)).StatusCode);
+ // The pages of the OAuth host belong to the Next.js site; the API answers none of them.
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/signin", Cancel)).StatusCode);
+
+ // People sign in and decide on the authorize host; clients call the issuer.
+ using HttpClient authorize = factory.CreateAuthorizeClient();
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/connect/authorize", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/connect/logout", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/api/v1/oauth/session", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/api/v1/oauth/allowed", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await authorize.GetAsync("/.well-known/openid-configuration", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await authorize.PostAsync("/connect/token", null, Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await authorize.GetAsync("/connect/userinfo", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await authorize.GetAsync("/api/v1/oauth/applications", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,
+ (await authorize.GetAsync("/api/v1/oauth/session", Cancel)).StatusCode);
+
+ // Applications are managed in the Progmasoft Console only, and the console issues no tokens.
+ using HttpClient console = factory.CreateConsoleClient();
+ Assert.Equal(HttpStatusCode.NotFound, (await oauth.GetAsync("/api/v1/oauth/applications", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await accounts.GetAsync("/api/v1/oauth/applications", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,
+ (await console.GetAsync("/api/v1/oauth/applications", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized, (await console.GetAsync("/api/v1/oauth/session", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await console.GetAsync("/.well-known/openid-configuration", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await console.GetAsync("/api/v1/oauth/allowed", Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await console.PostAsync("/connect/token", null, Cancel)).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await console.GetAsync("/api/v1/accounts/me", Cancel)).StatusCode);
+ // The console has no sign-in of its own.
+ Assert.Equal(HttpStatusCode.NotFound, (await console.PostAsync("/api/v1/oauth/signin", null, 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);
@@ -135,7 +208,7 @@ public sealed class OAuthPolicyTests
await developer.RegisterAndSignInAsync("PolicyHolder2", "policy2@example.com");
(string clientId, _) = await developer.RegisterApplicationAsync("profile");
- using HttpClient anonymous = factory.CreateOAuthClient();
+ using HttpClient anonymous = factory.CreateAuthorizeClient();
string path = OAuthTestClient.AuthorizePath(clientId, "openid", OAuthTestClient.Pkce().Challenge, prompt: "login");
HttpResponseMessage response = await anonymous.GetAsync(path, Cancel);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
@@ -150,28 +223,29 @@ public sealed class OAuthPolicyTests
}
[Fact]
- public async Task TheSignInPageGivesOneMessageForEveryFailureAndKeepsReturnAddressesLocal()
+ public async Task SignInGivesOneMessageForEveryFailureAndOnlyFromThisSite()
{
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/"
- });
+ HttpResponseMessage failed = await browser.SendAsync(HttpMethod.Post, "/api/v1/oauth/signin",
+ new { email = "nobody@example.com", password = "wrong password here" });
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);
+ Assert.Contains("The email address or the password is not right.",
+ await failed.Content.ReadAsStringAsync(Cancel), 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);
+ HttpResponseMessage forged = await browser.SendAsync(HttpMethod.Post, "/api/v1/oauth/signin",
+ new { email = "a@example.com", password = "x" }, origin: "https://attacker.example");
+ Assert.Equal(HttpStatusCode.Forbidden, forged.StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,
+ (await browser.SendAsync(HttpMethod.Get, "/api/v1/oauth/session")).StatusCode);
- Assert.Equal("/", OAuthAccount.LocalReturnUrl("//attacker.example"));
- Assert.Equal("/", OAuthAccount.LocalReturnUrl("/\\attacker.example"));
- Assert.Equal("/developers", OAuthAccount.LocalReturnUrl("/developers"));
+ await browser.RegisterAndSignInAsync("PolicyHolder3", "policy3@example.com");
+ JsonElement session = await browser.Browser.GetFromJsonAsync<JsonElement>("/api/v1/oauth/session", Cancel);
+ Assert.Equal("PolicyHolder3", session.GetProperty("accountName").GetString());
+ Assert.Equal(HttpStatusCode.NoContent,
+ (await browser.SendAsync(HttpMethod.Post, "/api/v1/oauth/signout")).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,
+ (await browser.SendAsync(HttpMethod.Get, "/api/v1/oauth/session")).StatusCode);
}
[Fact]
@@ -182,30 +256,90 @@ public sealed class OAuthPolicyTests
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"
- });
+ HttpResponseMessage refused = await owner.SendAsync(HttpMethod.Post, "/api/v1/oauth/applications", new
+ {
+ kind = "web",
+ displayName = "Plain HTTP",
+ redirectUris = new List<string> { "http://plain.example/callback" }
+ });
Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
Assert.Contains("cannot be registered", await refused.Content.ReadAsStringAsync(Cancel),
StringComparison.Ordinal);
+ HttpResponseMessage noKind = await owner.SendAsync(HttpMethod.Post, "/api/v1/oauth/applications", new
+ {
+ kind = "confidential",
+ displayName = "Old form",
+ redirectUris = new[] { OAuthTestClient.RedirectUri }
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, noKind.StatusCode);
+ HttpResponseMessage foreign = await owner.SendAsync(HttpMethod.Post, "/api/v1/oauth/applications", new
+ {
+ kind = "web",
+ displayName = "From elsewhere",
+ redirectUris = new[] { OAuthTestClient.RedirectUri }
+ }, origin: "https://attacker.example");
+ Assert.Equal(HttpStatusCode.Forbidden, foreign.StatusCode);
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);
+ (await stranger.SendAsync(HttpMethod.Get, $"/api/v1/oauth/applications/{clientId}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound,
- (await owner.Browser.GetAsync($"/developers/{clientId}", Cancel)).StatusCode);
+ (await stranger.SendAsync(HttpMethod.Delete, $"/api/v1/oauth/applications/{clientId}")).StatusCode);
+ JsonElement listed = await owner.Console.GetFromJsonAsync<JsonElement>("/api/v1/oauth/applications", Cancel);
+ JsonElement only = Assert.Single(listed.GetProperty("applications").EnumerateArray());
+ Assert.Equal("web", only.GetProperty("kind").GetString());
+ Assert.Equal(10, listed.GetProperty("maximum").GetInt32());
+
+ HttpResponseMessage saved = await owner.SendAsync(HttpMethod.Put, $"/api/v1/oauth/applications/{clientId}", new
+ {
+ kind = "desktop",
+ displayName = "Renamed Client",
+ redirectUris = new[] { OAuthTestClient.RedirectUri },
+ scopes = new List<string> { "profile", "email" }
+ });
+ JsonElement view = await saved.Content.ReadFromJsonAsync<JsonElement>(Cancel);
+ Assert.True(saved.StatusCode == HttpStatusCode.OK, view.ToString());
+ Assert.Equal("Renamed Client", view.GetProperty("displayName").GetString());
+ // The type is chosen once, when the token is made.
+ Assert.Equal("web", view.GetProperty("kind").GetString());
+ Assert.Equal(["profile", "email"], view.GetProperty("scopes").EnumerateArray().Select(scope => scope.GetString()));
+
+ Assert.Equal(HttpStatusCode.NoContent,
+ (await owner.SendAsync(HttpMethod.Delete, $"/api/v1/oauth/applications/{clientId}")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound,
+ (await owner.SendAsync(HttpMethod.Get, $"/api/v1/oauth/applications/{clientId}")).StatusCode);
+ }
+
+ [Fact]
+ public async Task EachTypeGetsItsSecretAndGrants()
+ {
+ await using AccountApiFactory factory = new();
+ OAuthTestClient owner = new(factory);
+ await owner.RegisterAndSignInAsync("PolicyOwner3", "owner3@example.com");
+ (_, string? web) = await owner.RegisterApplicationAsync("profile");
+ (_, string? spa) = await owner.RegisterApplicationAsync("profile", kind: "spa",
+ redirectUris: "https://app.example/callback");
+ (_, string? mobile) = await owner.RegisterApplicationAsync("profile", kind: "mobile",
+ redirectUris: "com.example.app:/callback");
+ (string serviceId, string? service) = await owner.RegisterServiceAsync();
+ Assert.NotNull(web);
+ Assert.Null(spa);
+ Assert.Null(mobile);
+ Assert.NotNull(service);
+
+ // A service never reaches the sign-in or consent page.
+ HttpResponseMessage authorize = await owner.Browser.GetAsync(OAuthTestClient.AuthorizePath(serviceId, "openid",
+ OAuthTestClient.Pkce().Challenge), Cancel);
+ Assert.Equal(HttpStatusCode.BadRequest, authorize.StatusCode);
+ // A web application does not use the client credentials grant; that is what a service token is for.
+ (string webId, string? webSecret) = await owner.RegisterApplicationAsync("profile");
+ (HttpStatusCode status, JsonElement error) = await owner.TokenAsync(new Dictionary<string, string>
+ {
+ ["grant_type"] = "client_credentials", ["client_id"] = webId, ["client_secret"] = webSecret!
+ });
+ Assert.Equal(HttpStatusCode.BadRequest, status);
+ Assert.Equal("unauthorized_client", error.GetProperty("error").GetString());
}
[Fact]
@@ -214,13 +348,12 @@ public sealed class OAuthPolicyTests
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);
+ (string clientId, string? secret) = await owner.RegisterServiceAsync();
- 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);
+ HttpResponseMessage rotated = await owner.SendAsync(HttpMethod.Post,
+ $"/api/v1/oauth/applications/{clientId}/secret");
+ JsonElement credentials = await rotated.Content.ReadFromJsonAsync<JsonElement>(Cancel);
+ string newSecret = credentials.GetProperty("clientSecret").GetString()!;
Assert.NotEqual(secret, newSecret);
(HttpStatusCode old, _) = await owner.TokenAsync(new Dictionary<string, string>
@@ -236,17 +369,66 @@ public sealed class OAuthPolicyTests
}
[Fact]
- public async Task PagesAllowTheirStylesheetByDigestAndNoScript()
+ public async Task TheTokenEndpointAnswersOnlyTheOriginsOfSinglePageApplications()
{
await using AccountApiFactory factory = new();
+ OAuthTestClient owner = new(factory);
+ await owner.RegisterAndSignInAsync("PolicyOwner4", "owner4@example.com");
+ await owner.RegisterApplicationAsync("profile", kind: "spa", redirectUris: "https://app.example/callback");
+ // A web application on another origin does not open that origin to the browser.
+ await owner.RegisterApplicationAsync("profile", redirectUris: "https://server.example/callback");
using HttpClient client = factory.CreateOAuthClient();
- HttpResponseMessage response = await client.GetAsync("/signin", Cancel);
- string policy = string.Join(";", response.Headers.GetValues("Content-Security-Policy"));
- string digest = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(HtmlPage.Stylesheet)));
- Assert.Contains($"style-src 'sha256-{digest}'", policy, StringComparison.Ordinal);
- Assert.Contains("default-src 'none'", policy, StringComparison.Ordinal);
- Assert.Contains("frame-ancestors 'none'", policy, StringComparison.Ordinal);
- Assert.DoesNotContain("script-src", policy, StringComparison.Ordinal);
+
+ async Task<HttpResponseMessage> PreflightAsync(string path, string origin)
+ {
+ HttpRequestMessage request = new(HttpMethod.Options, path);
+ request.Headers.Add("Origin", origin);
+ request.Headers.Add("Access-Control-Request-Method", "POST");
+ return await client.SendAsync(request, Cancel);
+ }
+
+ HttpResponseMessage allowed = await PreflightAsync("/connect/token", "https://app.example");
+ Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode);
+ Assert.Equal("https://app.example", allowed.Headers.GetValues("Access-Control-Allow-Origin").Single());
+ Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials"));
+ foreach (string origin in new[]
+ {
+ "https://server.example", "https://attacker.example", "https://app.example.attacker.example",
+ "https://app.example/callback", "null"
+ })
+ {
+ HttpResponseMessage refused = await PreflightAsync("/connect/token", origin);
+ Assert.False(refused.Headers.Contains("Access-Control-Allow-Origin"), origin);
+ }
+
+ HttpRequestMessage discovery = new(HttpMethod.Get, "/.well-known/openid-configuration");
+ discovery.Headers.Add("Origin", "https://anyone.example");
+ HttpResponseMessage document = await client.SendAsync(discovery, Cancel);
+ Assert.Equal("*", document.Headers.GetValues("Access-Control-Allow-Origin").Single());
+ }
+
+ [Fact]
+ public void ProductionSharesTheAccountSessionWithTheConsoleUnderProgmasoftCom()
+ {
+ // The console has no sign-in of its own: it reads the account session, so production shares that cookie with
+ // progmasoft.com. A cookie with a domain cannot keep the __Host- prefix; it takes __Secure-.
+ using JsonDocument settings = JsonDocument.Parse(File.ReadAllText(
+ Path.Combine(AppContext.BaseDirectory, "appsettings.json")));
+ JsonElement sessions = settings.RootElement.GetProperty("AccountSessions");
+ Assert.Equal("__Secure-ProgmasoftSession", sessions.GetProperty("CookieName").GetString());
+ Assert.Equal("progmasoft.com", sessions.GetProperty("CookieDomain").GetString());
+
+ Progmasoft.Progmaweb.Api.Accounts.SessionService service = new(null!, TimeProvider.System,
+ Microsoft.Extensions.Options.Options.Create(new Progmasoft.Progmaweb.Api.Accounts.AccountSessionOptions
+ {
+ CookieName = "__Secure-ProgmasoftSession",
+ CookieDomain = "progmasoft.com"
+ }));
+ Microsoft.AspNetC