using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Http { [Trait("Category", "Integration")] public sealed class OAuthTests : IAsyncLifetime { PrivaPubHost _host; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; async Task NewPersona(string name = "oauth") => await _host.Persona(await _host.SignUp(), name); static async Task SignedIn(HttpClient client, OAuthApp app) { var response = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); return response.StatusCode == HttpStatusCode.OK; } static async Task AssertMastodonError(HttpResponseMessage response, HttpStatusCode status) { Assert.Equal(status, response.StatusCode); Assert.False(string.IsNullOrEmpty((await response.JsonBody())["error"]?.GetValue())); } [Fact] public async Task The_token_names_the_persona_and_never_the_root() { var persona = await NewPersona(); var root = persona.Root; using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); var exchanged = await client.Exchange(app, await client.Code(app, persona)); var body = await exchanged.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, exchanged.StatusCode); Assert.DoesNotContain(root.Id, body); Assert.DoesNotContain(root.UserName, body); var token = JsonNode.Parse(body)!["access_token"]!.GetValue(); using var api = _host.As(token); var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); Assert.Contains($"\"id\":\"{persona.Id}\"", account); Assert.DoesNotContain(root.Id, account); Assert.DoesNotContain(root.UserName, account); var stored = await ClientApi.StoredTokens(app.ClientId); Assert.NotEmpty(stored); foreach (var entry in stored) { Assert.Equal(persona.Id, entry["subject"].AsString); var raw = entry.ToString(); var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty; Assert.DoesNotContain(root.Id, raw + payload); Assert.DoesNotContain(root.UserName, raw + payload); } var access = stored.Single(t => ClientApi.IsAccessToken(t)); Assert.Equal(persona.Id, JsonNode.Parse(ClientApi.JwtPayload(access["payload"].AsString))!["sub"]!.GetValue()); } [Fact] public async Task A_wrong_password_shows_an_error_and_signs_nobody_in() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); var response = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("That username and password do not match.", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.DoesNotContain(response.Headers.TryGetValues("Set-Cookie", out var cookies) ? cookies : Enumerable.Empty(), c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal)); var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode); Assert.StartsWith("/oauth/login?", authorize.Headers.Location!.OriginalString); } [Fact] public async Task A_login_without_the_antiforgery_token_is_refused() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); await client.GetStringAsync("/oauth/login", TestContext.Current.CancellationToken); var response = await client.Form("/oauth/login", ("returnUrl", "/oauth/authorize?x=1"), ("userName", persona.Root.UserName), ("password", persona.Root.Password)); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.False(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal))); } [Theory] [InlineData("https://evil.example/oauth/authorize?client_id=x")] [InlineData("//evil.example/oauth/authorize?client_id=x")] [InlineData("/\\evil.example/oauth/authorize?client_id=x")] [InlineData("/somewhere/else")] public async Task The_return_address_never_leaves_the_site(string returnUrl) { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var response = await client.SignIn(persona.Root, returnUrl); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("/oauth/authorize?signed_in=1", response.Headers.Location!.OriginalString); } [Fact] public async Task Denying_answers_access_denied_and_no_code() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); var response = await client.Choose(app, persona.Id, decision: "deny"); var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Null(ClientApi.CodeIn(page)); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); var location = response.Headers.Location!.OriginalString; Assert.StartsWith(ClientApi.OutOfBand + "?error=access_denied", location); Assert.DoesNotContain("code=", location); Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken); } [Fact] public async Task Choosing_another_roots_persona_shows_the_choice_again_without_a_code() { var mine = await NewPersona(); var theirs = await NewPersona("theirs"); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(mine.Root, app.ReturnUrl())).StatusCode); var response = await client.Choose(app, theirs.Id); var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Null(ClientApi.CodeIn(page)); Assert.Contains(mine.Id, page); Assert.DoesNotContain(theirs.Id, page); Assert.DoesNotContain(theirs.UserName, page); Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken); } [Fact] public async Task A_banned_root_is_sent_back_to_the_login() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); var choice = await client.Choice(app); await ClientApi.Ban(persona.Root.Id); var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); var chosen = await client.Decide(choice, persona.Id); var again = await client.SignIn(persona.Root, app.ReturnUrl()); Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode); Assert.StartsWith("/oauth/login?returnUrl=", authorize.Headers.Location!.OriginalString); Assert.Equal(HttpStatusCode.Redirect, chosen.StatusCode); Assert.StartsWith("/oauth/login?returnUrl=", chosen.Headers.Location!.OriginalString); Assert.Equal(HttpStatusCode.OK, again.StatusCode); Assert.Contains("That username and password do not match.", await again.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task A_code_issued_before_a_ban_buys_no_token() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); var code = await client.Code(app, persona); await ClientApi.Ban(persona.Root.Id); var response = await client.Exchange(app, code); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("invalid_grant", (await response.JsonBody())["error"]!.GetValue()); } [Fact] public async Task Force_login_asks_for_the_password_again() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); Assert.True(await SignedIn(client, app)); var forced = await client.GetAsync(app.ReturnUrl("force_login=true"), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Redirect, forced.StatusCode); var location = forced.Headers.Location!.OriginalString; Assert.StartsWith("/oauth/login?returnUrl=", location); var returnUrl = Uri.UnescapeDataString(location["/oauth/login?returnUrl=".Length..]); Assert.Contains("force_login=true", returnUrl); Assert.DoesNotContain("signed_in", returnUrl); var signedIn = await client.SignIn(persona.Root, returnUrl); Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); Assert.EndsWith("&signed_in=1", signedIn.Headers.Location!.OriginalString); var page = await client.GetAsync(signedIn.Headers.Location!.OriginalString, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, page.StatusCode); Assert.Contains(persona.Id, await page.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task An_authorization_code_works_once() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); var code = await client.Code(app, persona); var first = await client.Exchange(app, code); var second = await client.Exchange(app, code); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); Assert.Equal("invalid_grant", (await second.JsonBody())["error"]!.GetValue()); using var api = _host.As((await first.JsonBody())["access_token"]!.GetValue()); await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); } [Theory] [InlineData("password")] [InlineData("refresh_token")] public async Task Grants_other_than_code_and_client_credentials_are_refused(string grant) { var persona = await NewPersona(); using var client = _host.Client(); var app = await client.RegisterApp(); var response = await client.Form("/oauth/token", ("grant_type", grant), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), ("username", persona.Root.UserName), ("password", persona.Root.Password), ("refresh_token", "anything"), ("scope", "read")); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.DoesNotContain("access_token", body); Assert.Equal("unsupported_grant_type", JsonNode.Parse(body)!["error"]!.GetValue()); } [Fact] public async Task A_client_credentials_token_is_no_persona() { using var client = _host.Client(); var app = await client.RegisterApp("read write"); var response = await client.Form("/oauth/token", ("grant_type", "client_credentials"), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), ("scope", "read")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var token = (await response.JsonBody())["access_token"]!.GetValue(); using var api = _host.As(token); Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/apps/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); await AssertMastodonError(await api.GetAsync("/api/v1/notifications", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); } [Fact] public async Task A_read_only_token_cannot_post() { var token = await _host.MastodonToken(await NewPersona(), "read"); using var api = _host.As(token); var response = await api.Form("/api/v1/statuses", ("status", "nope")); await AssertMastodonError(response, HttpStatusCode.Forbidden); Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); } [Fact] public async Task The_follow_scope_covers_reading_follows() { var token = await _host.MastodonToken(await NewPersona(), "follow"); using var api = _host.As(token); Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/follow_requests", TestContext.Current.CancellationToken)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/blocks", TestContext.Current.CancellationToken)).StatusCode); await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Forbidden); } [Fact] public async Task A_revoked_token_stops_working() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); var token = await client.Token(app, persona); using var api = _host.As(token); Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", app.ClientId), ("client_secret", app.ClientSecret)); Assert.Equal(HttpStatusCode.OK, revoked.StatusCode); await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); } [Fact] public async Task The_login_and_authorize_pages_are_hardened() { var persona = await NewPersona(); using var client = _host.Client(cookies: true); var app = await client.RegisterApp(); var login = await client.GetAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(app.ReturnUrl()), TestContext.Current.CancellationToken); var failed = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!"); Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); foreach (var response in new[] { login, failed, authorize }) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); var csp = string.Join(";", response.Headers.GetValues("Content-Security-Policy")); Assert.Contains("default-src 'none'", csp); Assert.Contains("frame-ancestors 'none'", csp); Assert.Contains("no-store", response.Headers.CacheControl!.ToString()); } } [Fact] public async Task The_eleventh_login_from_one_address_in_a_minute_is_refused() { using var client = _host.ClientAt("198.51.100.12"); for (var attempt = 1; attempt <= 10; attempt++) Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken)).StatusCode); var eleventh = await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken); using var elsewhere = _host.ClientAt("198.51.100.13"); var other = await elsewhere.GetAsync("/oauth/login", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode); Assert.Equal(HttpStatusCode.OK, other.StatusCode); } } }