diff --git a/src/FishyFlip/ATProtocol.cs b/src/FishyFlip/ATProtocol.cs index c0808d04..c82e6f0d 100644 --- a/src/FishyFlip/ATProtocol.cs +++ b/src/FishyFlip/ATProtocol.cs @@ -235,7 +235,7 @@ public async Task GenerateOAuth2AuthenticationUrlAsync(string clientId, /// . /// Cancellation Token. /// String of Host URI if it could be resolved, null if it could not. - public async Task ResolveATHandleHostAsync(ATHandle handle, CancellationToken? token = default) + public async Task> ResolveATHandleHostAsync(ATHandle handle, CancellationToken? token = default) { string? host = this.options.DidCache.FirstOrDefault(n => n.Key == handle.ToString()).Value; if (!string.IsNullOrEmpty(host)) @@ -259,7 +259,7 @@ public async Task GenerateOAuth2AuthenticationUrlAsync(string clientId, try { - var endpointUrl = $"{Constants.Urls.ATProtoServer.SocialApi}{IdentityEndpoints.ResolveHandle}?handle={handle}"; + var endpointUrl = $"{Constants.Urls.ATProtoServer.PublicApi}{IdentityEndpoints.ResolveHandle}?handle={handle}"; var result = await this.Client.GetAsync(endpointUrl, token ?? CancellationToken.None); if (result.IsSuccessStatusCode) { @@ -268,7 +268,7 @@ await result.Content.ReadAsStringAsync(), this.options.SourceGenerationContext.ComAtprotoIdentityResolveHandleOutput); if (resolveHandle?.Did is not null) { - host = await this.ResolveATDidHostAsync(resolveHandle.Did, token); + (host, var error) = await this.ResolveATDidHostAsync(resolveHandle.Did, token); if (!string.IsNullOrEmpty(host)) { this.options.DidCache[handle.ToString()] = host!; @@ -277,6 +277,7 @@ await result.Content.ReadAsStringAsync(), else { this.options.Logger?.LogError($"Failed to resolve Handle: {handle}, missing Service Handle."); + return error; } } else @@ -286,6 +287,15 @@ await result.Content.ReadAsStringAsync(), } else { + var resolveError = JsonSerializer.Deserialize( + await result.Content.ReadAsStringAsync(), + this.options.SourceGenerationContext.ATError); + if (resolveError is not null) + { + this.options.Logger?.LogError($"Failed to resolve Handle: {handle}. {resolveError}"); + return resolveError; + } + this.options.Logger?.LogError($"Failed to resolve Handle: {handle}. {result.StatusCode}"); } } @@ -303,7 +313,7 @@ await result.Content.ReadAsStringAsync(), /// . /// Cancellation Token. /// String of Host URI if it could be resolved, null if it could not. - public async Task ResolveATDidHostAsync(ATDid did, CancellationToken? token = default) + public async Task> ResolveATDidHostAsync(ATDid did, CancellationToken? token = default) { string? host = this.options.DidCache.FirstOrDefault(n => n.Key == did.ToString()).Value; if (!string.IsNullOrEmpty(host)) @@ -317,7 +327,12 @@ await result.Content.ReadAsStringAsync(), switch (did.Type) { case "plc": - host = await this.ResolvePlcDidAsync(did, token); + (host, var error) = await this.ResolvePlcDidAsync(did, token); + if (error is not null) + { + return error; + } + break; case "web": host = await this.ResolveWebDidAsync(did, token); @@ -366,7 +381,7 @@ private void OnSessionUpdated(object? sender, SessionUpdatedEventArgs e) this.SessionUpdated?.Invoke(sender, e); } - private async Task ResolvePlcDidAsync(ATDid did, CancellationToken? token) + private async Task> ResolvePlcDidAsync(ATDid did, CancellationToken? token) { string? host = null; if (this.IsAuthenticated && this.Session?.Did.ToString() == did.ToString()) @@ -397,6 +412,7 @@ private void OnSessionUpdated(object? sender, SessionUpdatedEventArgs e) else { this.options.Logger?.LogError($"Failed to resolve plc DID: {did}. {error?.ToString()}"); + return error; } return host; diff --git a/src/FishyFlip/PasswordSessionManager.cs b/src/FishyFlip/PasswordSessionManager.cs index a89bd42d..65af3058 100644 --- a/src/FishyFlip/PasswordSessionManager.cs +++ b/src/FishyFlip/PasswordSessionManager.cs @@ -103,6 +103,52 @@ public Task RefreshSessionAsync(CancellationToken cancellationToken = default) /// A Task that represents the asynchronous operation. The task result contains a Result object with the session details, or null if the session could not be created. internal async Task> CreateSessionAsync(string identifier, string password, string? authFactorToken = default, CancellationToken cancellationToken = default) { + var host = this.protocol.Options.Url.ToString(); + var usingPublicApi = host.Contains(Constants.Urls.ATProtoServer.PublicApi); + if (usingPublicApi) + { + this.logger?.LogInformation($"Using public server {host} to login, resolving identifier to find host."); + var emailRegex = new Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$"); + if (emailRegex.IsMatch(identifier)) + { + this.logger?.LogWarning($"Using email address {identifier} to login to {host}. This is not recommended. Please use your handle instead."); + } + else if (ATIdentifier.TryCreate(identifier, out var ident)) + { + string? resolveHost = null; + if (ident is ATHandle handle) + { + (resolveHost, var resolveHandleError) = await this.protocol.ResolveATHandleHostAsync(handle, cancellationToken); + if (resolveHandleError is not null) + { + this.logger?.LogError($"Error resolving handle {identifier}: {resolveHandleError}"); + } + } + else if (ident is ATDid did) + { + (resolveHost, var resolveDidError) = await this.protocol.ResolveATDidHostAsync(did, cancellationToken); + if (resolveDidError is not null) + { + this.logger?.LogError($"Error resolving did {identifier}: {resolveDidError}"); + } + } + + if (Uri.TryCreate(resolveHost, UriKind.Absolute, out var uri)) + { + this.protocol.Options.Url = uri; + this.logger?.LogInformation($"Resolved handle {identifier} to {uri}, setting for authentication"); + } + else + { + this.logger?.LogWarning($"Could not resolve identifier {identifier} to a valid host."); + } + } + else + { + this.logger?.LogWarning($"Could not validate identifier: {identifier}"); + } + } + #pragma warning disable CS0618 var (session, error) = await this.protocol.CreateSessionAsync(identifier, password, authFactorToken, cancellationToken: cancellationToken); #pragma warning restore CS0618 @@ -147,6 +193,12 @@ public Task RefreshSessionAsync(CancellationToken cancellationToken = default) if (error is not null) { this.logger?.LogError(error.ToString()); + if (usingPublicApi) + { + this.logger?.LogInformation($"Login failed, Resetting to public server {Constants.Urls.ATProtoServer.PublicApi}."); + this.protocol.Options.Url = new Uri(Constants.Urls.ATProtoServer.PublicApi); + } + return error; } diff --git a/src/FishyFlip/Tools/ATProtocolExtensions.cs b/src/FishyFlip/Tools/ATProtocolExtensions.cs index 55f9793d..147cce70 100644 --- a/src/FishyFlip/Tools/ATProtocolExtensions.cs +++ b/src/FishyFlip/Tools/ATProtocolExtensions.cs @@ -153,7 +153,7 @@ public static async Task> Post( private static async Task ResolveHostUri(this ATProtocol protocol, string pathAndQueryString) { var logger = protocol.Options.Logger; - string host = string.Empty; + string? host = null; // Find repo name in pathAndQueryString if (pathAndQueryString.Contains("repo")) @@ -165,11 +165,11 @@ private static async Task ResolveHostUri(this ATProtocol protocol, strin var repo = repoName.Split('=')[1]; if (ATDid.TryCreate(repo, out ATDid? did)) { - host = await protocol.ResolveATDidHostAsync(did!) ?? string.Empty; + (host, _) = await protocol.ResolveATDidHostAsync(did!); } else if (ATHandle.TryCreate(repo, out ATHandle? handle)) { - host = await protocol.ResolveATHandleHostAsync(handle!) ?? string.Empty; + (host, _) = await protocol.ResolveATHandleHostAsync(handle!); } } } @@ -182,7 +182,7 @@ private static async Task ResolveHostUri(this ATProtocol protocol, strin var did = didName.Split('=')[1]; if (ATDid.TryCreate(did, out ATDid? atdid)) { - host = await protocol.ResolveATDidHostAsync(atdid!) ?? string.Empty; + (host, _) = await protocol.ResolveATDidHostAsync(atdid!); } } } @@ -214,7 +214,7 @@ private static async Task ResolveHostUri(this ATProtocol protocol, strin throw new InvalidOperationException("Host is required."); } - if (host.EndsWith("/") && pathAndQueryString.StartsWith("/")) + if (host!.EndsWith("/") && pathAndQueryString.StartsWith("/")) { host = host[0..^1]; } diff --git a/website/docs/logging-in.md b/website/docs/logging-in.md index 54794a50..9d4dd3b4 100644 --- a/website/docs/logging-in.md +++ b/website/docs/logging-in.md @@ -23,6 +23,8 @@ Console.WriteLine($"Session Handle: {session.Handle}"); Console.WriteLine($"Session Token: {session.AccessJwt}"); ``` +If you don't override the Instance URL in `ATProtocolBuilder.WithInstanceUrl` the users PDS Host will be resolved before the authentication attempt is made and will be used for authentication and future requests. If you have set it, the authentication request will be resolved against that endpoint. + - OAuth authentication is more complex. There is a full example showing a [local user authentication session](https://github.com/drasticactions/BSkyOAuthTokenGenerator/tree/main/src/BSkyOAuthTokenGenerator) but in short, you must: - Starting the session with `atProtocol.GenerateOAuth2AuthenticationUrlAsync` - Sending the user to a web browser to log in