From c1b180febb8ac4a413d89737f38a2804e712638a Mon Sep 17 00:00:00 2001
From: Darcy Ye <darcyye@silverhand.io>
Date: Thu, 14 Nov 2024 12:30:05 +0800
Subject: [PATCH] feat: support identifiers, first_screen, direct_sign_in and
 extra_params sign-in params (#32)

---
 samples/sample-blazor/Program.cs              | 31 ++++++-
 .../sample-mvc/Controllers/HomeController.cs  | 28 +++++-
 samples/sample/Pages/Index.cshtml.cs          | 28 +++++-
 .../LogtoParameters.cs                        | 93 +++++++++++++++++++
 .../AuthenticationBuilderExtensions.cs        | 41 +++++++-
 5 files changed, 216 insertions(+), 5 deletions(-)

diff --git a/samples/sample-blazor/Program.cs b/samples/sample-blazor/Program.cs
index 014eb11..6022c9a 100644
--- a/samples/sample-blazor/Program.cs
+++ b/samples/sample-blazor/Program.cs
@@ -47,8 +47,35 @@
 {
     if (!(context.User?.Identity?.IsAuthenticated ?? false))
     {
-        await context.ChallengeAsync(new AuthenticationProperties { RedirectUri = "/" });
-    } else {
+        var authProperties = new AuthenticationProperties 
+        { 
+            RedirectUri = "/" 
+        };
+
+        /// <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#first-screen"/>
+        /// <see cref="LogtoParameters.Authentication.FirstScreen"/>
+        authProperties.SetParameter("first_screen", LogtoParameters.Authentication.FirstScreen.Register);
+        
+        // This parameter MUST be used together with `first_screen`.
+        authProperties.SetParameter("identifiers", string.Join(",", new[] 
+        {
+            LogtoParameters.Authentication.Identifiers.Username,
+        }));
+
+        var directSignIn = new LogtoParameters.Authentication.DirectSignIn
+        {
+            Target = "github",
+            Method = LogtoParameters.Authentication.DirectSignIn.Methods.Social
+        };
+        
+        /// <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#direct-sign-in"/>
+        /// <see cref="LogtoParameters.Authentication.DirectSignIn"/>
+        authProperties.SetParameter("direct_sign_in", System.Text.Json.JsonSerializer.Serialize(directSignIn));
+
+        await context.ChallengeAsync(authProperties);
+    } 
+    else 
+    {
         context.Response.Redirect("/");
     }
 });
diff --git a/samples/sample-mvc/Controllers/HomeController.cs b/samples/sample-mvc/Controllers/HomeController.cs
index b1dfa96..b0c813d 100644
--- a/samples/sample-mvc/Controllers/HomeController.cs
+++ b/samples/sample-mvc/Controllers/HomeController.cs
@@ -3,6 +3,7 @@
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Mvc;
 using sample_mvc.Models;
+using System.Text.Json;
 
 namespace sample_mvc.Controllers;
 
@@ -25,7 +26,32 @@ public async Task<IActionResult> Index()
 
     public IActionResult SignIn()
     {
-        return Challenge(new AuthenticationProperties { RedirectUri = "/" });
+        var authProperties = new AuthenticationProperties 
+        { 
+            RedirectUri = "/" 
+        };
+
+        /// <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#first-screen"/>
+        /// <see cref="LogtoParameters.Authentication.FirstScreen"/>
+        authProperties.SetParameter("first_screen", LogtoParameters.Authentication.FirstScreen.Register);
+        
+        // This parameter MUST be used together with `first_screen`.
+        authProperties.SetParameter("identifiers", string.Join(",", new[] 
+        {
+            LogtoParameters.Authentication.Identifiers.Username,
+        }));
+
+        var directSignIn = new LogtoParameters.Authentication.DirectSignIn
+        {
+            Target = "github",
+            Method = LogtoParameters.Authentication.DirectSignIn.Methods.Social
+        };
+
+        /// <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#direct-sign-in"/>
+        /// <see cref="LogtoParameters.Authentication.DirectSignIn"/>
+        authProperties.SetParameter("direct_sign_in", JsonSerializer.Serialize(directSignIn));
+
+        return Challenge(authProperties);
     }
 
     // Use the `new` keyword to avoid conflict with the `ControllerBase.SignOut` method
diff --git a/samples/sample/Pages/Index.cshtml.cs b/samples/sample/Pages/Index.cshtml.cs
index ec836dc..1a94fc1 100644
--- a/samples/sample/Pages/Index.cshtml.cs
+++ b/samples/sample/Pages/Index.cshtml.cs
@@ -1,6 +1,7 @@
 using Logto.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.Text.Json;
 
 namespace sample.Pages;
 
@@ -22,7 +23,32 @@ public async Task OnGetAsync()
 
     public async Task OnPostSignInAsync()
     {
-        await HttpContext.ChallengeAsync(new AuthenticationProperties { RedirectUri = "/" });
+        var authProperties = new AuthenticationProperties 
+        { 
+            RedirectUri = "/" 
+        };
+
+        /// <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#first-screen"/>
+        /// <see cref="LogtoParameters.Authentication.FirstScreen"/>
+        authProperties.SetParameter("first_screen", LogtoParameters.Authentication.FirstScreen.Register);
+        
+        // This parameter MUST be used together with `first_screen`
+        authProperties.SetParameter("identifiers", string.Join(",", new[] 
+        {
+            LogtoParameters.Authentication.Identifiers.Username,
+        }));
+
+        var directSignIn = new LogtoParameters.Authentication.DirectSignIn
+        {
+            Target = "github",
+            Method = LogtoParameters.Authentication.DirectSignIn.Methods.Social
+        };
+        
+        /// <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#direct-sign-in"/>
+        /// <see cref="LogtoParameters.Authentication.DirectSignIn"/>
+        authProperties.SetParameter("direct_sign_in", JsonSerializer.Serialize(directSignIn));
+
+        await HttpContext.ChallengeAsync(authProperties);
     }
 
     public async Task OnPostSignOutAsync()
diff --git a/src/Logto.AspNetCore.Authentication/LogtoParameters.cs b/src/Logto.AspNetCore.Authentication/LogtoParameters.cs
index 86a4cca..58b62b2 100644
--- a/src/Logto.AspNetCore.Authentication/LogtoParameters.cs
+++ b/src/Logto.AspNetCore.Authentication/LogtoParameters.cs
@@ -1,4 +1,5 @@
 using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using System.Collections.Generic;
 
 namespace Logto.AspNetCore.Authentication;
 
@@ -115,4 +116,96 @@ public static class Claims
     /// </summary>
     public const string Identities = "identities";
   }
+
+  /// <summary>
+  /// The authentication parameters for Logto sign-in experience customization.
+  /// </summary>
+  public static class Authentication
+  {
+    /// <summary>
+    /// The first screen to show in the sign-in experience.
+    /// See <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#first-screen"/> for more details.
+    /// </summary>
+    public static class FirstScreen
+    {
+      /// <summary>
+      /// Show the register form first.
+      /// </summary>
+      public const string Register = "identifier:register";
+
+      /// <summary>
+      /// Show the sign-in form first.
+      /// </summary>
+      public const string SignIn = "identifier:sign_in";
+
+      /// <summary>
+      /// Show the single sign-on form first.
+      /// </summary>
+      public const string SingleSignOn = "single_sign_on";
+
+      /// <summary>
+      /// Show the reset password form first.
+      /// </summary>
+      public const string ResetPassword = "reset_password";
+    }
+
+    /// <summary>
+    /// The identifiers to use for authentication.
+    /// This parameter MUST be used together with <see cref="FirstScreen"/>.
+    /// </summary>
+    public static class Identifiers
+    {
+      /// <summary>
+      /// Use email for authentication.
+      /// </summary>
+      public const string Email = "email";
+      
+      /// <summary>
+      /// Use phone for authentication.
+      /// </summary>
+      public const string Phone = "phone";
+      
+      /// <summary>
+      /// Use username for authentication.
+      /// </summary>
+      public const string Username = "username";
+    }
+
+    /// <summary>
+    /// Direct sign-in configuration.
+    /// See <see href="https://docs.logto.io/docs/references/openid-connect/authentication-parameters/#direct-sign-in"/> for more details.
+    /// </summary>
+    public class DirectSignIn
+    {
+      /// <summary>
+      /// The target identifier for direct sign-in.
+      /// </summary>
+      public string Target { get; set; } = string.Empty;
+
+      /// <summary>
+      /// The sign-in method.
+      /// </summary>
+      public string Method { get; set; } = string.Empty;
+
+      public static class Methods
+      {
+        /// <summary>
+        /// Single sign-on method.
+        /// </summary>
+        public const string Sso = "sso";
+
+        /// <summary>
+        /// Social sign-in method.
+        /// </summary>
+        public const string Social = "social";
+      }
+    }
+
+    /// <summary>
+    /// Extra parameters to be passed to the authorization endpoint.
+    /// </summary>
+    public class ExtraParams : Dictionary<string, string>
+    {
+    }
+  }
 }
diff --git a/src/Logto.AspNetCore.Authentication/extensions/AuthenticationBuilderExtensions.cs b/src/Logto.AspNetCore.Authentication/extensions/AuthenticationBuilderExtensions.cs
index 4d4485f..2688a3d 100644
--- a/src/Logto.AspNetCore.Authentication/extensions/AuthenticationBuilderExtensions.cs
+++ b/src/Logto.AspNetCore.Authentication/extensions/AuthenticationBuilderExtensions.cs
@@ -9,6 +9,7 @@ namespace Logto.AspNetCore.Authentication;
 using Microsoft.IdentityModel.Tokens;
 using System;
 using System.Collections.Generic;
+using System.Threading.Tasks;
 
 /// <summary>
 /// Extension methods to configure Logto authentication.
@@ -101,11 +102,49 @@ private static void ConfigureOpenIdConnectOptions(OpenIdConnectOptions options,
     options.ClaimActions.MapAllExcept("nbf", "nonce", "c_hash", "at_hash");
     options.Events = new OpenIdConnectEvents
     {
+      OnRedirectToIdentityProvider = context =>
+      {
+        if (context.Properties.Parameters.TryGetValue("first_screen", out var firstScreen))
+        {
+          context.ProtocolMessage.Parameters.Add("first_screen", firstScreen?.ToString());
+        }
+
+        if (context.Properties.Parameters.TryGetValue("identifiers", out var identifiers))
+        {
+          context.ProtocolMessage.Parameters.Add("identifiers", identifiers?.ToString());
+        }
+
+        if (context.Properties.Parameters.TryGetValue("direct_sign_in", out var directSignIn))
+        {
+          var directSignInOption = System.Text.Json.JsonSerializer.Deserialize<LogtoParameters.Authentication.DirectSignIn>(
+            directSignIn?.ToString() ?? "{}"
+          );
+          if (directSignInOption != null && !string.IsNullOrEmpty(directSignInOption.Method) && !string.IsNullOrEmpty(directSignInOption.Target))
+          {
+            context.ProtocolMessage.Parameters.Add("direct_sign_in", $"{directSignInOption.Method}:{directSignInOption.Target}");
+          }
+        }
+
+        if (context.Properties.Parameters.TryGetValue("extra_params", out var extraParams))
+        {
+          var parameters = System.Text.Json.JsonSerializer.Deserialize<LogtoParameters.Authentication.ExtraParams>(
+            extraParams?.ToString() ?? "{}"
+          );
+          if (parameters != null)
+          {
+            foreach (var param in parameters)
+            {
+              context.ProtocolMessage.Parameters.Add(param.Key, param.Value);
+            }
+          }
+        }
+
+        return Task.CompletedTask;
+      },
       OnRedirectToIdentityProviderForSignOut = async context =>
       {
         // Clean up the cookie when signing out.
         await context.HttpContext.SignOutAsync(cookieScheme);
-
         // Rebuild parameters since we use <c>client_id</c> for sign-out, no need to use <c>id_token_hint</c>.
         context.ProtocolMessage.Parameters.Remove(OpenIdConnectParameterNames.IdTokenHint);
         context.ProtocolMessage.Parameters.Add(OpenIdConnectParameterNames.ClientId, logtoOptions.AppId);