* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
+240
View File
@@ -0,0 +1,240 @@
package cli
import (
"fmt"
"os"
"regexp"
"strings"
"cosmossdk.io/log"
"github.com/cosmos/cosmos-sdk/client"
"github.com/spf13/cobra"
)
func AddAuthCmds(rootCmd *cobra.Command) {
authCmd := &cobra.Command{
Use: "auth",
Short: "User authentication with Passkeys",
PreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
// Add auth commands
authCmd.AddCommand(
authLoginCmd(),
authRegisterCmd(),
)
// Add to root command
rootCmd.AddCommand(authCmd)
}
func authLoginCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "Login with WebAuthn authentication using email or phone",
Long: `Login to your existing identity using WebAuthn/Passkey authentication.
This command will:
1. Start a local auth server
2. Open your browser for WebAuthn credential authentication
3. Verify your existing WebAuthn credential
4. Unlock your DWN vault for data access
You must provide the same email or phone number used during registration.`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.NewLogger(os.Stderr)
// Get email flag
email, err := cmd.Flags().GetString("email")
if err != nil {
return fmt.Errorf("failed to get email flag: %w", err)
}
// Get tel flag
tel, err := cmd.Flags().GetString("tel")
if err != nil {
return fmt.Errorf("failed to get tel flag: %w", err)
}
// Validate that exactly one assertion method is provided
if email == "" && tel == "" {
return fmt.Errorf("you must provide either --email or --tel")
}
if email != "" && tel != "" {
return fmt.Errorf("please provide only one assertion method (--email or --tel)")
}
// Validate email format if provided
if email != "" && !isValidEmail(email) {
return fmt.Errorf("invalid email format: %s", email)
}
// Validate phone format if provided
if tel != "" && !isValidPhone(tel) {
return fmt.Errorf("invalid phone format: %s (must be E.164 format like +1234567890)", tel)
}
// Use assertion value as identifier
identifier := email
if tel != "" {
identifier = tel
}
logger.Info("Starting WebAuthn login", "identifier", identifier)
// Execute WebAuthn login
if err := LoginUserWithWebAuthn(identifier); err != nil {
logger.Error("WebAuthn login failed", "error", err)
return fmt.Errorf("WebAuthn login failed: %w", err)
}
logger.Info("WebAuthn login completed successfully", "identifier", identifier)
fmt.Printf("✅ Successfully logged in with: %s\n", identifier)
return nil
},
}
// Add assertion method flags (one is required)
cmd.Flags().StringP("email", "e", "", "Email address used during registration")
cmd.Flags().StringP("tel", "t", "", "Phone number used during registration (E.164 format)")
return cmd
}
func authRegisterCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "register",
Short: "Register a new identity using WebAuthn with email or phone",
Long: `Register a new decentralized identity using WebAuthn/Passkey authentication.
This command will:
1. Start a local auth server
2. Open your browser for WebAuthn credential creation
3. Create a DID document using your email or phone as the assertion method
4. Auto-create a DWN vault for data storage
5. Initialize UCAN delegation chain for authorization
You must provide either an email address or phone number as your primary identifier.`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.NewLogger(os.Stderr)
// Get client context for transaction broadcasting
clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return fmt.Errorf("failed to get client context: %w", err)
}
// Get auto-vault flag
autoCreateVault, err := cmd.Flags().GetBool("auto-vault")
if err != nil {
return fmt.Errorf("failed to get auto-vault flag: %w", err)
}
// Get email flag for assertion method
email, err := cmd.Flags().GetString("email")
if err != nil {
return fmt.Errorf("failed to get email flag: %w", err)
}
// Get tel flag for assertion method
tel, err := cmd.Flags().GetString("tel")
if err != nil {
return fmt.Errorf("failed to get tel flag: %w", err)
}
// Validate that exactly one assertion method is provided
if email == "" && tel == "" {
return fmt.Errorf("you must provide either --email or --tel")
}
if email != "" && tel != "" {
return fmt.Errorf("please provide only one assertion method (--email or --tel)")
}
// Validate email format if provided
if email != "" && !isValidEmail(email) {
return fmt.Errorf("invalid email format: %s", email)
}
// Validate phone format if provided
if tel != "" && !isValidPhone(tel) {
return fmt.Errorf("invalid phone format: %s (must be E.164 format like +1234567890)", tel)
}
// Determine assertion type and value
var assertionType, assertionValue string
if email != "" {
assertionType = "email"
assertionValue = email
logger.Info("Starting WebAuthn registration with email assertion", "email", email)
} else {
assertionType = "tel"
assertionValue = tel
logger.Info("Starting WebAuthn registration with phone assertion", "tel", tel)
}
// Execute WebAuthn registration and broadcast to blockchain
if err := RegisterUserWithWebAuthnAndBroadcastWithAssertion(
clientCtx, "", autoCreateVault, assertionType, assertionValue,
); err != nil {
logger.Error("WebAuthn registration failed", "error", err)
return fmt.Errorf("WebAuthn registration failed: %w", err)
}
logger.Info("WebAuthn registration completed successfully",
"assertionType", assertionType,
"assertionValue", assertionValue)
fmt.Printf("✅ Successfully registered identity\n")
fmt.Printf(" Assertion method: %s (%s)\n", assertionType, assertionValue)
if autoCreateVault {
fmt.Printf(" Vault: Auto-created\n")
}
return nil
},
}
// Add assertion method flags (one is required)
cmd.Flags().StringP("email", "e", "", "Email address for identity (e.g., alice@example.com)")
cmd.Flags().StringP("tel", "t", "", "Phone number for identity (E.164 format, e.g., +1234567890)")
// Add auto-vault flag
cmd.Flags().Bool("auto-vault", true, "Automatically create vault for DID (default: true)")
return cmd
}
// isValidEmail validates email format
func isValidEmail(email string) bool {
// Basic email validation regex
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return emailRegex.MatchString(email)
}
// isValidPhone validates phone number in E.164 format
func isValidPhone(phone string) bool {
// E.164 format: + followed by 1-15 digits
if !strings.HasPrefix(phone, "+") {
return false
}
// Remove the + and check if the rest are digits
digits := phone[1:]
if len(digits) < 1 || len(digits) > 15 {
return false
}
for _, ch := range digits {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
+136
View File
@@ -0,0 +1,136 @@
// Package cli contains the implementation of the CLI commands
package cli
import (
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strings"
"time"
"cosmossdk.io/log"
"github.com/sonr-io/sonr/x/did/client/server"
)
// LoginUserWithWebAuthn authenticates a user using WebAuthn through browser interaction
func LoginUserWithWebAuthn(username string) error {
logger := log.NewLogger(os.Stderr)
// If no username provided, prompt for it using standard input
if strings.TrimSpace(username) == "" {
var err error
username, err = promptForUsername()
if err != nil {
return fmt.Errorf("failed to get username: %w", err)
}
}
// Initialize database and check if username exists
if err := server.InitDB(); err != nil {
logger.Warn("Failed to initialize database", "error", err)
return fmt.Errorf("failed to initialize database: %w", err)
}
// Check if username exists with WebAuthn credentials
service := server.NewWebAuthnCredentialService()
existingCredentials, err := service.GetByUsername(username)
if err != nil || len(existingCredentials) == 0 {
return fmt.Errorf(
"username '%s' not found or has no WebAuthn credentials. Please register first.",
username,
)
}
logger.Info(
"Found WebAuthn credentials for user",
"username",
username,
"credentialCount",
len(existingCredentials),
)
// Find available port for auth server
port, err := findAvailablePortForLogin()
if err != nil {
return fmt.Errorf("failed to find available port: %w", err)
}
// Create channel to signal completion
done := make(chan error, 1)
// Setup server with WebAuthn login context
err = server.StartAuthServerForLogin(port, username, done)
if err != nil {
return fmt.Errorf("failed to start auth server: %w", err)
}
defer func() {
if stopErr := server.StopAuthServer(); stopErr != nil {
logger.Error("Failed to stop auth server", "error", stopErr)
}
}()
// Wait for server to be ready
time.Sleep(500 * time.Millisecond)
// Open browser to WebAuthn login page
url := fmt.Sprintf("http://localhost:%d/login?username=%s", port, username)
logger.Info("Opening browser for WebAuthn login", "url", url)
if err := openBrowserForLogin(url); err != nil {
logger.Warn("Failed to open browser automatically", "error", err)
logger.Info("Please navigate manually to the URL", "url", url)
}
logger.Info("Waiting for WebAuthn login to complete...")
// Wait for login to complete or timeout (30 seconds for login vs 10 for registration)
select {
case err := <-done:
if err != nil {
return fmt.Errorf("WebAuthn login failed: %w", err)
}
logger.Info("WebAuthn login completed successfully")
return nil
case <-time.After(30 * time.Second):
logger.Warn("WebAuthn login timed out after 30 seconds")
return fmt.Errorf("WebAuthn login timed out after 30 seconds - please try again")
}
}
// findAvailablePortForLogin finds an available port starting from 8090 to avoid conflicts with registration
func findAvailablePortForLogin() (int, error) {
for port := 8090; port < 8100; port++ {
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err == nil {
_ = conn.Close()
return port, nil
}
}
return 0, fmt.Errorf("no available port found in range 8090-8100")
}
// openBrowserForLogin opens the default browser with the given login URL
func openBrowserForLogin(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd = "open"
args = []string{url}
case "linux":
cmd = "xdg-open"
args = []string{url}
case "windows":
cmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
// #nosec G204 - cmd is hardcoded based on OS, not user input
return exec.Command(cmd, args...).Start()
}
+719
View File
@@ -0,0 +1,719 @@
package cli
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strings"
"time"
"cosmossdk.io/log"
"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/tx/signing"
authsigning "github.com/cosmos/cosmos-sdk/x/auth/signing"
webauthnutils "github.com/sonr-io/sonr/types/webauthn"
"github.com/sonr-io/sonr/x/did/client/server"
"github.com/sonr-io/sonr/x/did/types"
)
// RegisterUserWithWebAuthn registers a new user using WebAuthn through browser interaction
func RegisterUserWithWebAuthn(username string) error {
logger := log.NewLogger(os.Stderr)
// If no username provided, prompt for it using standard input
if strings.TrimSpace(username) == "" {
var err error
username, err = promptForUsername()
if err != nil {
return fmt.Errorf("failed to get username: %w", err)
}
}
// Initialize database and check if username already exists
if err := server.InitDB(); err != nil {
logger.Warn("Failed to initialize database", "error", err)
// Continue without username check - database may not be available
} else {
// Check if username already exists
service := server.NewWebAuthnCredentialService()
existingCredentials, err := service.GetByUsername(username)
if err == nil && len(existingCredentials) > 0 {
return fmt.Errorf("username '%s' already exists with %d WebAuthn credential(s)", username, len(existingCredentials))
}
// If error occurred (like record not found), continue with registration
}
// Find available port for auth server
port, err := findAvailablePort()
if err != nil {
return fmt.Errorf("failed to find available port: %w", err)
}
// Create channel to signal completion
done := make(chan error, 1)
// Setup server with WebAuthn registration context
err = server.StartAuthServerWithWebAuthn(port, username, done)
if err != nil {
return fmt.Errorf("failed to start auth server: %w", err)
}
defer func() {
if stopErr := server.StopAuthServer(); stopErr != nil {
logger.Error("Failed to stop auth server", "error", stopErr)
}
}()
// Wait for server to be ready
time.Sleep(500 * time.Millisecond)
// Open browser to WebAuthn registration page
url := fmt.Sprintf("http://localhost:%d/register?username=%s", port, username)
logger.Info("Opening browser for WebAuthn registration", "url", url)
if err := openBrowser(url); err != nil {
logger.Warn("Failed to open browser automatically", "error", err)
logger.Info("Please navigate manually to the URL", "url", url)
}
logger.Info("Waiting for WebAuthn registration to complete...")
// Wait for registration to complete or timeout
select {
case err := <-done:
if err != nil {
return fmt.Errorf("WebAuthn registration failed: %w", err)
}
logger.Info("WebAuthn registration completed successfully")
return nil
case <-time.After(30 * time.Second):
logger.Warn("WebAuthn registration timed out after 30 seconds")
return fmt.Errorf("WebAuthn registration timed out after 30 seconds - please try again")
}
}
// findAvailablePort finds an available port starting from 8080
func findAvailablePort() (int, error) {
for port := 8080; port < 8090; port++ {
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err == nil {
conn.Close()
return port, nil
}
}
return 0, fmt.Errorf("no available port found in range 8080-8090")
}
// openBrowser opens the default browser with the given URL
func openBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd = "open"
args = []string{url}
case "linux":
cmd = "xdg-open"
args = []string{url}
case "windows":
cmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return exec.Command(cmd, args...).Start()
}
// RegisterUserWithWebAuthnAndBroadcast registers a user with WebAuthn and broadcasts to blockchain
func RegisterUserWithWebAuthnAndBroadcast(
clientCtx client.Context,
username string,
autoCreateVault bool,
) error {
// Import necessary packages
var (
contextPkg = "context"
base64Pkg = "encoding/base64"
jsonPkg = "encoding/json"
flagsPkg = "github.com/cosmos/cosmos-sdk/client/flags"
txPkg = "github.com/cosmos/cosmos-sdk/client/tx"
sdkPkg = "github.com/cosmos/cosmos-sdk/types"
typesPkg = "github.com/sonr-io/sonr/x/did/types"
)
_ = contextPkg
_ = base64Pkg
_ = jsonPkg
_ = flagsPkg
_ = txPkg
_ = sdkPkg
_ = typesPkg
logger := log.NewLogger(os.Stderr)
// If no username provided, prompt for it using standard input
if strings.TrimSpace(username) == "" {
var err error
username, err = promptForUsername()
if err != nil {
return fmt.Errorf("failed to get username: %w", err)
}
}
// Initialize database and check if username already exists
if err := server.InitDB(); err != nil {
logger.Warn("Failed to initialize database", "error", err)
// Continue without username check - database may not be available
} else {
// Check if username already exists
service := server.NewWebAuthnCredentialService()
existingCredentials, err := service.GetByUsername(username)
if err == nil && len(existingCredentials) > 0 {
return fmt.Errorf("username '%s' already exists with %d WebAuthn credential(s)", username, len(existingCredentials))
}
// If error occurred (like record not found), continue with registration
}
// Find available port for auth server
port, err := findAvailablePort()
if err != nil {
return fmt.Errorf("failed to find available port: %w", err)
}
// Create channel to signal completion and pass WebAuthn credential data
done := make(chan error, 1)
credentialData := make(chan *server.WebAuthnCredential, 1)
// Setup server with WebAuthn registration context and credential data channel
err = server.StartAuthServerWithWebAuthnAndCredentialChannel(
port,
username,
done,
credentialData,
)
if err != nil {
return fmt.Errorf("failed to start auth server: %w", err)
}
defer func() {
if stopErr := server.StopAuthServer(); stopErr != nil {
logger.Error("Failed to stop auth server", "error", stopErr)
}
}()
// Wait for server to be ready
time.Sleep(500 * time.Millisecond)
// Open browser to WebAuthn registration page
url := fmt.Sprintf("http://localhost:%d/register?username=%s", port, username)
logger.Info("Opening browser for WebAuthn registration", "url", url)
if err := openBrowser(url); err != nil {
logger.Warn("Failed to open browser automatically", "error", err)
logger.Info("Please navigate manually to the URL", "url", url)
}
logger.Info("Waiting for WebAuthn registration to complete...")
// Wait for registration to complete or timeout
select {
case err := <-done:
if err != nil {
return fmt.Errorf("WebAuthn registration failed: %w", err)
}
logger.Info("WebAuthn registration completed successfully")
// Get the credential data from the server
select {
case credential := <-credentialData:
logger.Info("Received WebAuthn credential data, broadcasting to blockchain...",
"credentialID", credential.CredentialID, "username", credential.Username)
// Create and broadcast the MsgRegisterWebAuthnCredential transaction
err = broadcastWebAuthnCredential(clientCtx, credential, autoCreateVault)
if err != nil {
return fmt.Errorf("failed to broadcast WebAuthn credential: %w", err)
}
logger.Info(
"WebAuthn credential successfully broadcast to blockchain and vault creation initiated",
)
return nil
case <-time.After(2 * time.Second):
return fmt.Errorf("failed to receive credential data from server")
}
case <-time.After(30 * time.Second):
logger.Warn("WebAuthn registration timed out after 30 seconds")
return fmt.Errorf("WebAuthn registration timed out after 30 seconds - please try again")
}
}
// RegisterUserWithWebAuthnAndBroadcastWithAssertion registers a user with WebAuthn and assertion methods
func RegisterUserWithWebAuthnAndBroadcastWithAssertion(
clientCtx client.Context,
username string, // Can be empty, will use assertion value
autoCreateVault bool,
assertionType string,
assertionValue string,
) error {
// Import necessary packages
var (
contextPkg = "context"
base64Pkg = "encoding/base64"
jsonPkg = "encoding/json"
flagsPkg = "github.com/cosmos/cosmos-sdk/client/flags"
txPkg = "github.com/cosmos/cosmos-sdk/client/tx"
sdkPkg = "github.com/cosmos/cosmos-sdk/types"
typesPkg = "github.com/sonr-io/sonr/x/did/types"
)
_ = contextPkg
_ = base64Pkg
_ = jsonPkg
_ = flagsPkg
_ = txPkg
_ = sdkPkg
_ = typesPkg
logger := log.NewLogger(os.Stderr)
// Use assertion value as the identifier
identifier := assertionValue
// Initialize database and check if assertion already exists
if err := server.InitDB(); err != nil {
logger.Warn("Failed to initialize database", "error", err)
// Continue without check - database may not be available
} else {
// Check if assertion value already exists as a registered identity
service := server.NewWebAuthnCredentialService()
existingCredentials, err := service.GetByUsername(identifier)
if err == nil && len(existingCredentials) > 0 {
return fmt.Errorf("%s '%s' already registered with %d WebAuthn credential(s)",
assertionType, assertionValue, len(existingCredentials))
}
// If error occurred (like record not found), continue with registration
}
// Find available port for auth server
port, err := findAvailablePort()
if err != nil {
return fmt.Errorf("failed to find available port: %w", err)
}
// Create channel to signal completion and pass WebAuthn credential data
done := make(chan error, 1)
credentialData := make(chan *server.WebAuthnCredential, 1)
// Setup server with WebAuthn registration context and credential data channel
// Use the assertion value as the identifier for WebAuthn
err = server.StartAuthServerWithWebAuthnAndCredentialChannel(
port,
identifier,
done,
credentialData,
)
if err != nil {
return fmt.Errorf("failed to start auth server: %w", err)
}
defer func() {
if stopErr := server.StopAuthServer(); stopErr != nil {
logger.Error("Failed to stop auth server", "error", stopErr)
}
}()
// Wait for server to be ready
time.Sleep(500 * time.Millisecond)
// Open browser to WebAuthn registration page
url := fmt.Sprintf("http://localhost:%d/register?identifier=%s", port, identifier)
logger.Info("Opening browser for WebAuthn registration", "url", url)
if err := openBrowser(url); err != nil {
logger.Warn("Failed to open browser automatically", "error", err)
logger.Info("Please navigate manually to the URL", "url", url)
}
logger.Info("Waiting for WebAuthn registration to complete...")
// Wait for registration to complete or timeout
select {
case err := <-done:
if err != nil {
return fmt.Errorf("WebAuthn registration failed: %w", err)
}
logger.Info("WebAuthn registration completed successfully")
// Get the credential data from the server
select {
case credential := <-credentialData:
logger.Info("Received WebAuthn credential data, broadcasting to blockchain...",
"credentialID", credential.CredentialID,
"identifier", identifier,
"assertionType", assertionType,
"assertionValue", assertionValue)
// Create and broadcast the MsgRegisterWebAuthnCredential transaction with assertion
err = broadcastWebAuthnCredentialWithAssertion(
clientCtx, credential, autoCreateVault, assertionType, assertionValue,
)
if err != nil {
return fmt.Errorf("failed to broadcast WebAuthn credential: %w", err)
}
logger.Info(
"WebAuthn credential successfully broadcast to blockchain with assertion method",
"assertionType", assertionType,
)
return nil
case <-time.After(2 * time.Second):
return fmt.Errorf("failed to receive credential data from server")
}
case <-time.After(30 * time.Second):
logger.Warn("WebAuthn registration timed out after 30 seconds")
return fmt.Errorf("WebAuthn registration timed out after 30 seconds - please try again")
}
}
// broadcastWebAuthnCredential creates and broadcasts a MsgRegisterWebAuthnCredential transaction
func broadcastWebAuthnCredential(
clientCtx client.Context,
credential *server.WebAuthnCredential,
autoCreateVault bool,
) error {
logger := log.NewLogger(os.Stderr)
logger.Info("Broadcasting WebAuthn credential transaction",
"credentialID", credential.CredentialID,
"username", credential.Username,
"autoCreateVault", autoCreateVault,
"chainID", clientCtx.ChainID)
// Import required packages
didtypes := "github.com/sonr-io/sonr/x/did/types"
_ = didtypes
// For gasless transactions, we generate a deterministic address from the WebAuthn credential
// This allows the transaction to be processed without a pre-existing account
controllerAddr := generateAddressFromWebAuthn(credential)
// Create the WebAuthn credential message
// PublicKey, Algorithm, and Origin are extracted server-side from attestation
webauthnCred := types.WebAuthnCredential{
CredentialId: credential.CredentialID,
RawId: credential.RawID,
ClientDataJson: credential.ClientDataJSON,
AttestationObject: credential.AttestationObject,
// Use the extracted fields from server processing
PublicKey: credential.PublicKey,
Algorithm: credential.Algorithm,
Origin: credential.Origin,
}
// Create the registration message
msg := &types.MsgRegisterWebAuthnCredential{
Controller: controllerAddr.String(),
Username: credential.Username,
WebauthnCredential: webauthnCred,
VerificationMethodId: fmt.Sprintf("webauthn-%s", credential.CredentialID[:8]),
AutoCreateVault: autoCreateVault,
}
// Build the transaction with proper signature structure for gasless handling
txBuilder := clientCtx.TxConfig.NewTxBuilder()
err := txBuilder.SetMsgs(msg)
if err != nil {
return fmt.Errorf("failed to set message: %w", err)
}
// Set reasonable gas limit for gasless transaction (fees will still be zero)
txBuilder.SetGasLimit(200000) // Reasonable gas limit for WebAuthn registration
txBuilder.SetFeeAmount(sdk.NewCoins()) // Zero fees - gasless
// For WebAuthn gasless transactions, we need to provide at least empty signature info
// to pass mempool validation, then our ante handler will bypass signature verification
logger.Info("Creating gasless WebAuthn transaction with empty signature placeholder",
"controllerAddress", controllerAddr.String(),
"credentialID", credential.CredentialID)
// For WebAuthn gasless transactions, we need to provide a dummy signature to pass
// mempool validation, then our ante handler will bypass the verification
logger.Info("Creating dummy signature for mempool validation bypass")
// Create a minimal dummy public key from the controller address
// This is needed so the signature validation doesn't fail immediately
pubKeyBytes := make(
[]byte,
33,
) // Standard secp256k1 compressed public key length
copy(pubKeyBytes[1:], controllerAddr.Bytes()[:32]) // Use controller address bytes
pubKeyBytes[0] = 0x02 // Compressed public key prefix
dummyPubKey := &secp256k1.PubKey{Key: pubKeyBytes}
// Create a minimal dummy signature structure to pass mempool validation
dummySig := signing.SignatureV2{
PubKey: dummyPubKey, // Dummy public key derived from controller address
Data: &signing.SingleSignatureData{
SignMode: signing.SignMode_SIGN_MODE_DIRECT,
Signature: make([]byte, 64), // Non-empty signature to pass basic checks
},
Sequence: 0, // Zero sequence for gasless
}
// Set the dummy signature to pass mempool validation
err = txBuilder.SetSignatures(dummySig)
if err != nil {
return fmt.Errorf("failed to set dummy signature: %w", err)
}
logger.Info(
"Dummy signature set for mempool bypass",
"pubKeyLen",
len(pubKeyBytes),
"sigLen",
64,
)
// Encode the transaction
tx := txBuilder.GetTx()
// Debug: Verify transaction has no signatures (expected for WebAuthn bypass)
if sigTx, ok := tx.(authsigning.SigVerifiableTx); ok {
sigs, err := sigTx.GetSignaturesV2()
if err != nil {
logger.Error("Failed to get signatures from tx", "error", err)
} else {
logger.Info("Transaction signature count", "sigCount", len(sigs))
}
}
txBytes, err := clientCtx.TxConfig.TxEncoder()(tx)
if err != nil {
return fmt.Errorf("failed to encode transaction: %w", err)
}
// Broadcast the transaction
res, err := clientCtx.BroadcastTxSync(txBytes)
if err != nil {
return fmt.Errorf("failed to broadcast transaction: %w", err)
}
// Check the response
if res.Code != 0 {
return fmt.Errorf("transaction failed with code %d: %s", res.Code, res.RawLog)
}
logger.Info("WebAuthn credential successfully registered",
"txHash", res.TxHash,
"height", res.Height,
"gasUsed", res.GasUsed)
// Parse the response to get the created DID
// In a real implementation, we would parse the events to extract the DID
logger.Info("DID created successfully",
"username", credential.Username,
"credentialID", credential.CredentialID,
"vaultCreated", autoCreateVault)
return nil
}
// generateAddressFromWebAuthn generates a deterministic address from WebAuthn credential
// using the centralized utility function from types/webauthn
func generateAddressFromWebAuthn(credential *server.WebAuthnCredential) sdk.AccAddress {
// Use the centralized address generation to ensure consistency
return webauthnutils.GenerateAddressFromCredential(credential.CredentialID)
}
// promptForUsername prompts the user for a username using standard input
func promptForUsername() (string, error) {
fmt.Print("Enter username for WebAuthn registration: ")
reader := bufio.NewReader(os.Stdin)
username, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read username input: %w", err)
}
username = strings.TrimSpace(username)
// Validate username
if username == "" {
return "", fmt.Errorf("username is required")
}
if len(username) < 3 {
return "", fmt.Errorf("username must be at least 3 characters")
}
if len(username) > 20 {
return "", fmt.Errorf("username cannot exceed 20 characters")
}
// Check for valid characters (alphanumeric and underscore)
for _, char := range username {
if (char < 'a' || char > 'z') &&
(char < 'A' || char > 'Z') &&
(char < '0' || char > '9') &&
char != '_' {
return "", fmt.Errorf(
"username can only contain alphanumeric characters and underscores",
)
}
}
return username, nil
}
// broadcastWebAuthnCredentialWithAssertion creates and broadcasts a MsgRegisterWebAuthnCredential transaction with assertion
func broadcastWebAuthnCredentialWithAssertion(
clientCtx client.Context,
credential *server.WebAuthnCredential,
autoCreateVault bool,
assertionType string,
assertionValue string,
) error {
logger := log.NewLogger(os.Stderr)
logger.Info("Broadcasting WebAuthn credential transaction with assertion",
"credentialID", credential.CredentialID,
"username", credential.Username,
"autoCreateVault", autoCreateVault,
"assertionType", assertionType,
"assertionValue", assertionValue,
"chainID", clientCtx.ChainID)
// Import required packages
didtypes := "github.com/sonr-io/sonr/x/did/types"
_ = didtypes
// For gasless transactions, we generate a deterministic address from the WebAuthn credential
// This allows the transaction to be processed without a pre-existing account
controllerAddr := generateAddressFromWebAuthn(credential)
// Create the WebAuthn credential message
// PublicKey, Algorithm, and Origin are extracted server-side from attestation
webauthnCred := types.WebAuthnCredential{
CredentialId: credential.CredentialID,
RawId: credential.RawID,
ClientDataJson: credential.ClientDataJSON,
AttestationObject: credential.AttestationObject,
// Use the extracted fields from server processing
PublicKey: credential.PublicKey,
Algorithm: credential.Algorithm,
Origin: credential.Origin,
}
// Create the registration message
// Use the assertion value directly as the username for the message
// The server will detect the type (email/tel) based on the format
msg := &types.MsgRegisterWebAuthnCredential{
Controller: controllerAddr.String(),
Username: assertionValue, // This will be the email or phone number
WebauthnCredential: webauthnCred,
VerificationMethodId: fmt.Sprintf("webauthn-%s", credential.CredentialID[:8]),
AutoCreateVault: autoCreateVault,
}
// Build the transaction with proper signature structure for gasless handling
txBuilder := clientCtx.TxConfig.NewTxBuilder()
err := txBuilder.SetMsgs(msg)
if err != nil {
return fmt.Errorf("failed to set message: %w", err)
}
// Set reasonable gas limit for gasless transaction (fees will still be zero)
txBuilder.SetGasLimit(200000) // Reasonable gas limit for WebAuthn registration
txBuilder.SetFeeAmount(sdk.NewCoins()) // Zero fees - gasless
// For WebAuthn gasless transactions, we need to provide at least empty signature info
// to pass mempool validation, then our ante handler will bypass signature verification
logger.Info("Creating gasless WebAuthn transaction with empty signature placeholder",
"controllerAddress", controllerAddr.String(),
"credentialID", credential.CredentialID)
// For WebAuthn gasless transactions, we need to provide a dummy signature to pass
// mempool validation, then our ante handler will bypass the verification
logger.Info("Creating dummy signature for mempool validation bypass")
// Create a minimal dummy public key from the controller address
// This is needed so the signature validation doesn't fail immediately
pubKeyBytes := make(
[]byte,
33,
) // Standard secp256k1 compressed public key length
copy(pubKeyBytes[1:], controllerAddr.Bytes()[:32]) // Use controller address bytes
pubKeyBytes[0] = 0x02 // Compressed public key prefix
dummyPubKey := &secp256k1.PubKey{Key: pubKeyBytes}
// Create a minimal dummy signature structure to pass mempool validation
dummySig := signing.SignatureV2{
PubKey: dummyPubKey, // Dummy public key derived from controller address
Data: &signing.SingleSignatureData{
SignMode: signing.SignMode_SIGN_MODE_DIRECT,
Signature: make([]byte, 64), // Non-empty signature to pass basic checks
},
Sequence: 0, // Zero sequence for gasless
}
// Set the dummy signature to pass mempool validation
err = txBuilder.SetSignatures(dummySig)
if err != nil {
return fmt.Errorf("failed to set dummy signature: %w", err)
}
logger.Info(
"Dummy signature set for mempool bypass",
"pubKeyLen",
len(pubKeyBytes),
"sigLen",
64,
)
// Encode the transaction
tx := txBuilder.GetTx()
// Debug: Verify transaction has no signatures (expected for WebAuthn bypass)
if sigTx, ok := tx.(authsigning.SigVerifiableTx); ok {
sigs, err := sigTx.GetSignaturesV2()
if err != nil {
logger.Error("Failed to get signatures from tx", "error", err)
} else {
logger.Info("Transaction signature count", "sigCount", len(sigs))
}
}
txBytes, err := clientCtx.TxConfig.TxEncoder()(tx)
if err != nil {
return fmt.Errorf("failed to encode transaction: %w", err)
}
// Broadcast the transaction
res, err := clientCtx.BroadcastTxSync(txBytes)
if err != nil {
return fmt.Errorf("failed to broadcast transaction: %w", err)
}
// Check the response
if res.Code != 0 {
return fmt.Errorf("transaction failed with code %d: %s", res.Code, res.RawLog)
}
logger.Info("WebAuthn credential with assertion successfully registered",
"txHash", res.TxHash,
"height", res.Height,
"gasUsed", res.GasUsed,
"assertionType", assertionType)
// Parse the response to get the created DID
// In a real implementation, we would parse the events to extract the DID
logger.Info("DID created successfully with assertion method",
"credentialID", credential.CredentialID,
"assertionType", assertionType,
"assertionValue", assertionValue,
"vaultCreated", autoCreateVault)
return nil
}
+335
View File
@@ -0,0 +1,335 @@
package cli
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/codec"
codectypes "github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/x/auth/tx"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/sonr-io/sonr/x/did/client/server"
didtypes "github.com/sonr-io/sonr/x/did/types"
)
// WebAuthnRegistrationTestSuite tests WebAuthn CLI registration flow
type WebAuthnRegistrationTestSuite struct {
suite.Suite
clientCtx client.Context
tempDir string
}
func TestWebAuthnRegistrationTestSuite(t *testing.T) {
suite.Run(t, new(WebAuthnRegistrationTestSuite))
}
func (s *WebAuthnRegistrationTestSuite) SetupSuite() {
// Create temporary directory for test database
tempDir, err := os.MkdirTemp("", "webauthn_test_*")
s.Require().NoError(err)
s.tempDir = tempDir
// Create basic codec and tx config for testing
interfaceRegistry := codectypes.NewInterfaceRegistry()
codec := codec.NewProtoCodec(interfaceRegistry)
// Create a basic tx config
txConfig := tx.NewTxConfig(codec, tx.DefaultSignModes)
// Set up client context for testing
s.clientCtx = client.Context{}.
WithCodec(codec).
WithTxConfig(txConfig).
WithHomeDir(tempDir).
WithFromName("testuser")
}
func (s *WebAuthnRegistrationTestSuite) TearDownSuite() {
// Clean up temporary directory
if s.tempDir != "" {
_ = os.RemoveAll(s.tempDir)
}
}
func (s *WebAuthnRegistrationTestSuite) TestPromptForUsername() {
tests := []struct {
name string
username string
wantErr bool
}{
{
name: "valid username",
username: "testuser123",
wantErr: false,
},
{
name: "username with underscore",
username: "test_user",
wantErr: false,
},
{
name: "too short username",
username: "ab",
wantErr: true,
},
{
name: "too long username",
username: "thisusernameistoolongandexceedstwentycharacters",
wantErr: true,
},
{
name: "invalid characters",
username: "test-user!",
wantErr: true,
},
{
name: "empty username",
username: "",
wantErr: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Note: We can't easily test the interactive prompt without complex setup
// Instead, we test the validation logic by checking expected behavior
if tt.wantErr {
// These usernames should fail validation
s.T().Logf("Username '%s' should fail validation", tt.username)
} else {
// These usernames should pass validation
s.T().Logf("Username '%s' should pass validation", tt.username)
}
})
}
}
func (s *WebAuthnRegistrationTestSuite) TestRegisterUserWithWebAuthn() {
// Mock HTTP server to simulate WebAuthn registration endpoints
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/begin-register":
// Mock WebAuthn challenge response
challenge := map[string]any{
"challenge": "dGVzdC1jaGFsbGVuZ2U",
"user": map[string]any{
"id": "dGVzdC11c2VyLWlk",
"name": "testuser",
"displayName": "Test User",
},
"rp": map[string]any{
"name": "Sonr Test",
"id": "localhost",
},
"pubKeyCredParams": []map[string]any{
{"type": "public-key", "alg": -7}, // ES256
{"type": "public-key", "alg": -257}, // RS256
},
"timeout": 30000,
"attestation": "none",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(challenge)
case "/finish-register":
// Mock successful registration response
response := map[string]any{
"success": true,
"message": "Registration successful",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
default:
http.NotFound(w, r)
}
}))
defer mockServer.Close()
// Set up database path for testing
dbPath := filepath.Join(s.tempDir, "test_vault.db")
// Initialize test database
err := server.InitDB()
s.Require().NoError(err, "Failed to initialize test database")
// Test username validation with existing user
username := "testuser"
// The function should complete without errors for valid input
// Note: In a real test, this would connect to a browser, but we're testing
// the setup and validation logic
s.T().Logf("Testing WebAuthn registration setup for username: %s", username)
s.T().Logf("Database path: %s", dbPath)
s.T().Logf("Mock server URL: %s", mockServer.URL)
// Verify that the username is properly validated
s.Require().Greater(len(username), 2, "Username should be longer than 2 characters")
s.Require().Less(len(username), 21, "Username should be shorter than 21 characters")
// Verify alphanumeric validation
for _, char := range username {
valid := (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '_'
s.Require().True(valid, "Username contains invalid character: %c", char)
}
}
func (s *WebAuthnRegistrationTestSuite) TestRegisterUserWithWebAuthnAndBroadcast() {
// Test the broadcast integration function
username := "broadcastuser"
s.Run("valid_username_broadcast", func() {
// Test with valid client context
s.Require().NotNil(s.clientCtx.Codec, "Client context should have codec")
s.Require().NotNil(s.clientCtx.TxConfig, "Client context should have tx config")
// The function should validate the username and prepare for WebAuthn
s.T().Logf("Testing broadcast registration for username: %s", username)
s.T().Logf("Client context home: %s", s.clientCtx.HomeDir)
})
s.Run("invalid_parameters", func() {
// Test with empty username - should prompt for input
emptyUsername := ""
s.T().Logf("Testing with empty username: '%s'", emptyUsername)
// Test with invalid client context
invalidCtx := client.Context{}
s.Require().Nil(invalidCtx.Codec, "Invalid context should have nil codec")
})
}
func (s *WebAuthnRegistrationTestSuite) TestDatabaseIntegration() {
// Test database operations for WebAuthn credentials
s.Run("database_initialization", func() {
// Initialize database
err := server.InitDB()
s.Require().NoError(err, "Database initialization should succeed")
})
s.Run("username_existence_check", func() {
// Test username existence checking
username := "dbtest_user"
// Initialize database for testing
err := server.InitDB()
s.Require().NoError(err, "Database should initialize successfully")
s.T().Logf("Testing username existence for: %s", username)
// The actual existence check would happen in the registration function
})
}
func (s *WebAuthnRegistrationTestSuite) TestServerLifecycle() {
// Test HTTP server lifecycle management
s.Run("server_startup_shutdown", func() {
// Test server configuration
port := 8080
rpID := "localhost"
s.T().Logf("Testing server lifecycle on port %d with RP ID: %s", port, rpID)
// Verify port is reasonable
s.Require().Greater(port, 1024, "Port should be above 1024")
s.Require().Less(port, 65536, "Port should be below 65536")
// Verify RP ID is valid
s.Require().NotEmpty(rpID, "RP ID should not be empty")
})
s.Run("timeout_handling", func() {
// Test timeout configuration
timeout := 10 * time.Second
s.T().Logf("Testing timeout handling: %v", timeout)
s.Require().Greater(timeout, 5*time.Second, "Timeout should be reasonable")
s.Require().Less(timeout, 60*time.Second, "Timeout should not be too long")
})
}
func (s *WebAuthnRegistrationTestSuite) TestWebAuthnCredentialValidation() {
// Test WebAuthn credential structure validation
s.Run("credential_data_structure", func() {
// Mock credential data structure
credentialData := map[string]any{
"id": "test-credential-id",
"rawId": "dGVzdC1jcmVkZW50aWFsLWlk",
"type": "public-key",
"response": map[string]any{
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIn0",
"attestationObject": "dGVzdC1hdHRlc3RhdGlvbi1vYmplY3Q",
},
}
// Validate credential structure
s.Require().NotNil(credentialData["id"], "Credential should have ID")
s.Require().NotNil(credentialData["rawId"], "Credential should have raw ID")
s.Require().NotNil(credentialData["type"], "Credential should have type")
s.Require().NotNil(credentialData["response"], "Credential should have response")
response, ok := credentialData["response"].(map[string]any)
s.Require().True(ok, "Response should be a map")
s.Require().NotNil(response["clientDataJSON"], "Response should have clientDataJSON")
s.Require().NotNil(response["attestationObject"], "Response should have attestationObject")
})
}
func (s *WebAuthnRegistrationTestSuite) TestIntegrationWithDIDModule() {
// Test integration between WebAuthn CLI and DID module
s.Run("did_integration_setup", func() {
// Test DID types and message structure
username := "didintegration_user"
// Verify DID message types are available
s.T().Logf("Testing DID integration for user: %s", username)
// Check that DID types are properly imported and available
s.Require().NotEmpty(didtypes.ModuleName, "DID module name should be available")
})
s.Run("transaction_building", func() {
// Test transaction building capabilities
s.Require().
NotNil(s.clientCtx.TxConfig, "TxConfig should be available for transaction building")
s.Require().NotNil(s.clientCtx.Codec, "Codec should be available for encoding")
// Test basic transaction builder setup
txBuilder := s.clientCtx.TxConfig.NewTxBuilder()
s.Require().NotNil(txBuilder, "Transaction builder should be created")
})
}
// BenchmarkWebAuthnRegistration benchmarks the WebAuthn registration process
func BenchmarkWebAuthnRegistration(b *testing.B) {
// Setup
tempDir, err := os.MkdirTemp("", "webauthn_bench_*")
require.NoError(b, err)
defer func() { _ = os.RemoveAll(tempDir) }()
// Initialize database for benchmarking
err = server.InitDB()
require.NoError(b, err)
b.ResetTimer()
for i := 0; i < b.N; i++ {
username := "benchuser"
// Benchmark username validation
valid := len(username) >= 3 && len(username) <= 20
if !valid {
b.Errorf("Username validation failed for: %s", username)
}
}
}
+248
View File
@@ -0,0 +1,248 @@
package server
import (
"fmt"
"os"
"path/filepath"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var db *gorm.DB
// InitDB initializes the SQLite database connection
func InitDB() error {
// Create ~/.sonr directory if it doesn't exist
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
sonrDir := filepath.Join(homeDir, ".sonr")
if mkdirErr := os.MkdirAll(sonrDir, 0o750); mkdirErr != nil {
return fmt.Errorf("failed to create .sonr directory: %w", mkdirErr)
}
// Database file path
dbPath := filepath.Join(sonrDir, "vault.db")
// Open SQLite database with GORM
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
// Disable GORM logging for cleaner CLI output
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
// Auto-migrate all models
err = db.AutoMigrate(
&StoredWebAuthnCredential{},
&UnsignedTransaction{},
&AccountInfo{},
&VaultInfo{},
&SessionInfo{},
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
}
return nil
}
// GetDB returns the database instance
func GetDB() *gorm.DB {
return db
}
// CloseDB closes the database connection
func CloseDB() error {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// WebAuthnCredentialService provides database operations for WebAuthn credentials
type WebAuthnCredentialService struct{}
// NewWebAuthnCredentialService creates a new WebAuthn credential service
func NewWebAuthnCredentialService() *WebAuthnCredentialService {
return &WebAuthnCredentialService{}
}
// Store saves a WebAuthn credential to the database
func (s *WebAuthnCredentialService) Store(credential *StoredWebAuthnCredential) error {
return db.Create(credential).Error
}
// GetByCredentialID retrieves a credential by its ID
func (s *WebAuthnCredentialService) GetByCredentialID(
credentialID string,
) (*StoredWebAuthnCredential, error) {
var credential StoredWebAuthnCredential
err := db.Where("credential_id = ?", credentialID).First(&credential).Error
if err != nil {
return nil, err
}
return &credential, nil
}
// GetByUsername retrieves all credentials for a username
func (s *WebAuthnCredentialService) GetByUsername(
username string,
) ([]StoredWebAuthnCredential, error) {
var credentials []StoredWebAuthnCredential
err := db.Where("username = ?", username).Find(&credentials).Error
return credentials, err
}
// UsernameExists checks if a username already has registered WebAuthn credentials
func (s *WebAuthnCredentialService) UsernameExists(username string) (bool, error) {
var count int64
err := db.Model(&StoredWebAuthnCredential{}).Where("username = ?", username).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// AccountInfoService provides database operations for account information
type AccountInfoService struct{}
// NewAccountInfoService creates a new account info service
func NewAccountInfoService() *AccountInfoService {
return &AccountInfoService{}
}
// Store saves account information to the database
func (s *AccountInfoService) Store(account *AccountInfo) error {
return db.Create(account).Error
}
// GetByUsername retrieves account info by username
func (s *AccountInfoService) GetByUsername(username string) (*AccountInfo, error) {
var account AccountInfo
err := db.Where("username = ?", username).First(&account).Error
if err != nil {
return nil, err
}
return &account, nil
}
// UpdateSequence updates the account sequence number
func (s *AccountInfoService) UpdateSequence(username string, sequence uint64) error {
return db.Model(&AccountInfo{}).
Where("username = ?", username).
Update("sequence", sequence).
Error
}
// VaultInfoService provides database operations for vault information
type VaultInfoService struct{}
// NewVaultInfoService creates a new vault info service
func NewVaultInfoService() *VaultInfoService {
return &VaultInfoService{}
}
// Store saves vault information to the database
func (s *VaultInfoService) Store(vault *VaultInfo) error {
return db.Create(vault).Error
}
// GetByVaultID retrieves vault info by vault ID
func (s *VaultInfoService) GetByVaultID(vaultID string) (*VaultInfo, error) {
var vault VaultInfo
err := db.Where("vault_id = ?", vaultID).First(&vault).Error
if err != nil {
return nil, err
}
return &vault, nil
}
// GetByUsername retrieves all vaults for a username
func (s *VaultInfoService) GetByUsername(username string) ([]VaultInfo, error) {
var vaults []VaultInfo
err := db.Where("username = ?", username).Find(&vaults).Error
return vaults, err
}
// UnsignedTransactionService provides database operations for unsigned transactions
type UnsignedTransactionService struct{}
// NewUnsignedTransactionService creates a new unsigned transaction service
func NewUnsignedTransactionService() *UnsignedTransactionService {
return &UnsignedTransactionService{}
}
// Store saves an unsigned transaction to the database
func (s *UnsignedTransactionService) Store(tx *UnsignedTransaction) error {
return db.Create(tx).Error
}
// GetByTxID retrieves a transaction by its ID
func (s *UnsignedTransactionService) GetByTxID(txID string) (*UnsignedTransaction, error) {
var tx UnsignedTransaction
err := db.Where("tx_id = ?", txID).First(&tx).Error
if err != nil {
return nil, err
}
return &tx, nil
}
// GetPendingByUsername retrieves all pending transactions for a username
func (s *UnsignedTransactionService) GetPendingByUsername(
username string,
) ([]UnsignedTransaction, error) {
var transactions []UnsignedTransaction
err := db.Where("username = ? AND status = ?", username, "pending").Find(&transactions).Error
return transactions, err
}
// UpdateStatus updates the transaction status
func (s *UnsignedTransactionService) UpdateStatus(txID, status string) error {
return db.Model(&UnsignedTransaction{}).Where("tx_id = ?", txID).Update("status", status).Error
}
// SessionInfoService provides database operations for session information
type SessionInfoService struct{}
// NewSessionInfoService creates a new session info service
func NewSessionInfoService() *SessionInfoService {
return &SessionInfoService{}
}
// Store saves session information to the database
func (s *SessionInfoService) Store(session *SessionInfo) error {
return db.Create(session).Error
}
// GetBySessionID retrieves a session by its ID
func (s *SessionInfoService) GetBySessionID(sessionID string) (*SessionInfo, error) {
var session SessionInfo
err := db.Where("session_id = ?", sessionID).First(&session).Error
if err != nil {
return nil, err
}
return &session, nil
}
// UpdateStatus updates the session status
func (s *SessionInfoService) UpdateStatus(sessionID, status string) error {
return db.Model(&SessionInfo{}).
Where("session_id = ?", sessionID).
Update("status", status).
Error
}
// CleanupExpiredSessions removes expired sessions
func (s *SessionInfoService) CleanupExpiredSessions() error {
return db.Where("expires_at < ?", fmt.Sprintf("%d", os.Getpid())).Delete(&SessionInfo{}).Error
}
+768
View File
@@ -0,0 +1,768 @@
package server
import (
"crypto/rand"
"encoding/base64"
"fmt"
"html/template"
"net/http"
"os"
"slices"
"time"
"cosmossdk.io/log"
"github.com/labstack/echo/v4"
"github.com/sonr-io/sonr/types/webauthn"
"github.com/sonr-io/sonr/types/webauthn/webauthncbor"
)
var logger = log.NewLogger(os.Stderr)
// HandleIndex handles the index route
func HandleIndex(c echo.Context) error {
return c.String(http.StatusOK, "Sonr Auth Server")
}
// HandleHealth handles the health route
func HandleHealth(c echo.Context) error {
return c.String(http.StatusOK, "OK")
}
// HandleLogin handles the basic login route
func HandleLogin(c echo.Context) error {
return c.String(http.StatusOK, "Login endpoint")
}
// HandleWebAuthnLogin serves the WebAuthn login HTML page
func HandleWebAuthnLogin(c echo.Context) error {
// Support both username and identifier parameters
username := c.QueryParam("username")
if username == "" {
username = c.QueryParam("identifier")
}
if username == "" {
return c.String(http.StatusBadRequest, "Username or identifier parameter required")
}
// Check if user exists
service := NewWebAuthnCredentialService()
credentials, err := service.GetByUsername(username)
if err != nil || len(credentials) == 0 {
return c.String(
http.StatusNotFound,
fmt.Sprintf("No WebAuthn credentials found for user: %s", username),
)
}
// Render the WebAuthn login page
tmpl := template.Must(template.New("webauthn-login").Parse(webAuthnLoginHTML))
return tmpl.Execute(c.Response().Writer, map[string]any{
"Username": username,
"RPID": "localhost",
"RPName": "Sonr Identity Platform",
})
}
// HandleBeginLogin starts the WebAuthn authentication ceremony
func HandleBeginLogin(c echo.Context) error {
var username string
// Handle both GET and POST requests
if c.Request().Method == "POST" {
// For POST requests, try to get username from body
var body map[string]string
if err := c.Bind(&body); err == nil {
username = body["username"]
}
}
// Fall back to query param for both GET and POST
if username == "" {
username = c.QueryParam("username")
}
// Also check for identifier parameter
if username == "" {
username = c.QueryParam("identifier")
}
if username == "" {
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Username or identifier parameter required"},
)
}
logger.Info("Starting WebAuthn authentication", "username", username)
// Check if user exists and get their credentials
service := NewWebAuthnCredentialService()
credentials, err := service.GetByUsername(username)
if err != nil || len(credentials) == 0 {
return c.JSON(
http.StatusNotFound,
map[string]string{
"error": fmt.Sprintf("No WebAuthn credentials found for user: %s", username),
},
)
}
// Generate challenge
challenge, err := generateChallenge()
if err != nil {
logger.Error("Failed to generate challenge", "error", err)
return c.JSON(
http.StatusInternalServerError,
map[string]string{"error": "Failed to generate challenge"},
)
}
// Create authentication options
allowCredentials := make([]map[string]any, len(credentials))
for i, cred := range credentials {
allowCredentials[i] = map[string]any{
"type": "public-key",
"id": cred.CredentialID,
}
}
options := map[string]any{
"challenge": challenge,
"timeout": 60000,
"rpId": "localhost",
"allowCredentials": allowCredentials,
"userVerification": "preferred", // Changed from required to preferred for broader compatibility
}
// Store challenge in session
if authServer != nil {
if authServer.sessionStore == nil {
authServer.sessionStore = make(map[string]string)
}
authServer.sessionStore[username] = challenge
}
logger.Info(
"Sending authentication options",
"username",
username,
"challenge",
challenge,
"credentialCount",
len(credentials),
)
return c.JSON(http.StatusOK, options)
}
// HandleFinishLogin completes the WebAuthn authentication ceremony
func HandleFinishLogin(c echo.Context) error {
username := c.QueryParam("username")
if username == "" {
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Username parameter required"},
)
}
// Parse authentication response from client
var authResponse map[string]any
if err := c.Bind(&authResponse); err != nil {
logger.Error("Failed to parse authentication response", "error", err)
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Invalid authentication response"},
)
}
logger.Info("Received authentication response", "username", username)
// Get stored challenge
var storedChallenge string
if authServer != nil && authServer.sessionStore != nil {
storedChallenge = authServer.sessionStore[username]
}
if storedChallenge == "" {
logger.Error("No stored challenge found", "username", username)
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "No challenge found for user"},
)
}
// Extract credential data from the response
credentialID, ok := authResponse["id"].(string)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid credential ID"})
}
response, ok := authResponse["response"].(map[string]any)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid response object"})
}
clientDataJSON, ok := response["clientDataJSON"].(string)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid client data JSON"})
}
// Verify client data and challenge for authentication
if err := verifyClientDataForAuthentication(clientDataJSON, storedChallenge); err != nil {
logger.Error("Client data verification failed for authentication", "error", err)
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Authentication verification failed"},
)
}
// Verify the credential exists for this user
service := NewWebAuthnCredentialService()
credential, err := service.GetByCredentialID(credentialID)
if err != nil {
logger.Error("Credential not found", "error", err, "credentialID", credentialID)
return c.JSON(
http.StatusNotFound,
map[string]string{"error": "Credential not found"},
)
}
if credential.Username != username {
logger.Error(
"Credential belongs to different user",
"credentialUser",
credential.Username,
"requestedUser",
username,
)
return c.JSON(
http.StatusUnauthorized,
map[string]string{"error": "Credential does not belong to this user"},
)
}
// Clean up session
if authServer != nil && authServer.sessionStore != nil {
delete(authServer.sessionStore, username)
}
// Signal completion to CLI
if authServer != nil && authServer.registrationDone != nil {
select {
case authServer.registrationDone <- nil:
logger.Info("Authentication completion signaled to CLI", "username", username)
default:
logger.Warn(
"Failed to signal authentication completion - channel full",
"username",
username,
)
}
}
logger.Info(
"WebAuthn authentication completed successfully",
"username",
username,
"credentialID",
credentialID,
)
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "Authentication completed successfully",
"credentialId": credentialID,
})
}
// HandleWebAuthnRegister serves the WebAuthn registration HTML page
func HandleWebAuthnRegister(c echo.Context) error {
// Support both username and identifier parameters
username := c.QueryParam("username")
if username == "" {
username = c.QueryParam("identifier")
}
if username == "" {
return c.String(http.StatusBadRequest, "Username or identifier parameter required")
}
// Render the WebAuthn registration page
tmpl := template.Must(template.New("webauthn-register").Parse(webAuthnRegistrationHTML))
return tmpl.Execute(c.Response().Writer, map[string]any{
"Username": username,
"RPID": "localhost",
"RPName": "Sonr Identity Platform",
})
}
// HandleBeginRegister starts the WebAuthn registration ceremony
func HandleBeginRegister(c echo.Context) error {
var username string
// Handle both GET and POST requests
if c.Request().Method == "POST" {
// For POST requests, try to get username from body
var body map[string]string
if err := c.Bind(&body); err == nil {
username = body["username"]
}
}
// Fall back to query param for both GET and POST
if username == "" {
username = c.QueryParam("username")
}
// Also check for identifier parameter
if username == "" {
username = c.QueryParam("identifier")
}
if username == "" {
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Username or identifier parameter required"},
)
}
logger.Info("Starting WebAuthn registration", "username", username)
// Generate challenge
challenge, err := generateChallenge()
if err != nil {
logger.Error("Failed to generate challenge", "error", err)
return c.JSON(
http.StatusInternalServerError,
map[string]string{"error": "Failed to generate challenge"},
)
}
// Create registration options
options := map[string]any{
"challenge": challenge,
"rp": map[string]string{
"id": "localhost",
"name": "Sonr Identity Platform",
},
"user": map[string]any{
"id": base64.URLEncoding.EncodeToString([]byte(username)),
"name": username,
"displayName": username,
},
"pubKeyCredParams": []map[string]any{
{
"type": "public-key",
"alg": -7, // ES256 algorithm (most common)
},
{
"type": "public-key",
"alg": -257, // RS256 algorithm
},
{
"type": "public-key",
"alg": -8, // EdDSA algorithm
},
},
"authenticatorSelection": map[string]any{
// Remove authenticatorAttachment to allow both platform and cross-platform authenticators
// "authenticatorAttachment": "platform", // Commented out to allow QR codes
"userVerification": "preferred", // Changed from required to preferred for broader compatibility
"residentKey": "preferred",
"requireResidentKey": false, // Allow non-resident keys for broader compatibility
},
"timeout": 60000,
"attestation": "none", // Changed from direct to none for broader compatibility
}
// Store challenge in session (in production, use proper session store)
if authServer != nil {
if authServer.sessionStore == nil {
authServer.sessionStore = make(map[string]string)
}
authServer.sessionStore[username] = challenge
}
logger.Info("Sending registration options", "username", username, "challenge", challenge)
return c.JSON(http.StatusOK, options)
}
// HandleFinishRegister completes the WebAuthn registration ceremony
func HandleFinishRegister(c echo.Context) error {
username := c.QueryParam("username")
if username == "" {
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Username parameter required"},
)
}
// Parse registration response from client
var regResponse map[string]any
if err := c.Bind(&regResponse); err != nil {
logger.Error("Failed to parse registration response", "error", err)
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Invalid registration response"},
)
}
logger.Info("Received registration response", "username", username)
// Get stored challenge
var storedChallenge string
if authServer != nil && authServer.sessionStore != nil {
storedChallenge = authServer.sessionStore[username]
}
if storedChallenge == "" {
logger.Error("No stored challenge found", "username", username)
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "No challenge found for user"},
)
}
// Extract credential data from the response
credentialID, ok := regResponse["id"].(string)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid credential ID"})
}
rawID, ok := regResponse["rawId"].(string)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid raw ID"})
}
response, ok := regResponse["response"].(map[string]any)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid response object"})
}
clientDataJSON, ok := response["clientDataJSON"].(string)
if !ok {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid client data JSON"})
}
attestationObject, ok := response["attestationObject"].(string)
if !ok {
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Invalid attestation object"},
)
}
// Verify client data and challenge
if err := verifyClientData(clientDataJSON, storedChallenge); err != nil {
logger.Error("Client data verification failed", "error", err)
return c.JSON(
http.StatusBadRequest,
map[string]string{"error": "Client data verification failed"},
)
}
// Create WebAuthn credential record
webAuthnCredential := &WebAuthnCredential{
CredentialID: credentialID,
RawID: rawID,
ClientDataJSON: clientDataJSON,
AttestationObject: attestationObject,
Username: username,
CreatedAt: time.Now(),
}
// Process the registration and store in database
if err := processWebAuthnRegistration(webAuthnCredential); err != nil {
logger.Error("Failed to process WebAuthn registration", "error", err)
return c.JSON(
http.StatusInternalServerError,
map[string]string{"error": "Registration processing failed"},
)
}
// Store WebAuthn credential in database
if err := storeWebAuthnCredential(webAuthnCredential); err != nil {
logger.Error("Failed to store WebAuthn credential in database", "error", err)
// Don't fail the registration if database storage fails
logger.Warn("Continuing registration despite database storage failure")
}
// Clean up session
if authServer != nil && authServer.sessionStore != nil {
delete(authServer.sessionStore, username)
}
// Send credential data to CLI if channel is available
if authServer != nil && authServer.credentialData != nil {
select {
case authServer.credentialData <- webAuthnCredential:
logger.Info(
"WebAuthn credential data sent to CLI",
"username",
username,
"credentialID",
credentialID,
)
default:
logger.Warn("Failed to send credential data - channel full", "username", username)
}
}
// Signal completion to CLI
if authServer != nil && authServer.registrationDone != nil {
select {
case authServer.registrationDone <- nil:
logger.Info("Registration completion signaled to CLI", "username", username)
default:
logger.Warn(
"Failed to signal registration completion - channel full",
"username",
username,
)
}
}
logger.Info(
"WebAuthn registration completed successfully",
"username",
username,
"credentialID",
credentialID,
)
return c.JSON(http.StatusOK, map[string]any{
"success": true,
"message": "Registration completed successfully",
"credentialId": credentialID,
})
}
// generateChallenge generates a cryptographically secure challenge
func generateChallenge() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// verifyClientData verifies the client data JSON and challenge using centralized WebAuthn validation
func verifyClientData(clientDataJSON, expectedChallenge string) error {
// Parse client data using the centralized WebAuthn protocol parser
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
if err != nil {
return fmt.Errorf("failed to parse client data: %w", err)
}
// Verify challenge
if clientData.Challenge != expectedChallenge {
return fmt.Errorf("challenge mismatch")
}
// Verify type
if clientData.Type != "webauthn.create" {
return fmt.Errorf("invalid client data type: %s", clientData.Type)
}
// Verify origin (adjust for your domain)
expectedOrigin := "http://localhost"
if clientData.Origin != expectedOrigin &&
!containsString(
clientData.Origin,
[]string{
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:8083",
"http://localhost:8084",
},
) {
return fmt.Errorf("invalid origin: %s", clientData.Origin)
}
return nil
}
// verifyClientDataForAuthentication verifies the client data JSON and challenge for authentication
func verifyClientDataForAuthentication(clientDataJSON, expectedChallenge string) error {
// Parse client data using the centralized WebAuthn protocol parser
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
if err != nil {
return fmt.Errorf("failed to parse client data: %w", err)
}
// Verify challenge
if clientData.Challenge != expectedChallenge {
return fmt.Errorf("challenge mismatch")
}
// Verify type for authentication (webauthn.get instead of webauthn.create)
if clientData.Type != "webauthn.get" {
return fmt.Errorf("invalid client data type for authentication: %s", clientData.Type)
}
// Verify origin (adjust for your domain)
expectedOrigin := "http://localhost"
if clientData.Origin != expectedOrigin &&
!containsString(
clientData.Origin,
[]string{
"http://localhost:8080",
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:8083",
"http://localhost:8084",
"http://localhost:8085",
"http://localhost:8086",
"http://localhost:8087",
"http://localhost:8088",
"http://localhost:8089",
},
) {
return fmt.Errorf("invalid origin: %s", clientData.Origin)
}
return nil
}
// containsString checks if a string is in a slice
func containsString(str string, slice []string) bool {
return slices.Contains(slice, str)
}
// processWebAuthnRegistration processes the WebAuthn registration and extracts required fields
func processWebAuthnRegistration(credential *WebAuthnCredential) error {
logger.Info(
"Processing WebAuthn registration",
"username",
credential.Username,
"credentialID",
credential.CredentialID,
)
// Extract origin from client data JSON
origin, err := extractOriginFromClientData(credential.ClientDataJSON)
if err != nil {
logger.Error("Failed to extract origin from client data", "error", err)
return fmt.Errorf("failed to extract origin: %w", err)
}
credential.Origin = origin
logger.Info("Extracted origin from client data", "origin", origin)
// Extract public key and algorithm from attestation object
publicKey, algorithm, err := extractPublicKeyFromAttestation(credential.AttestationObject)
if err != nil {
logger.Error("Failed to extract public key from attestation", "error", err)
return fmt.Errorf("failed to extract public key: %w", err)
}
credential.PublicKey = publicKey
credential.Algorithm = algorithm
logger.Info("Extracted public key from attestation",
"algorithm", algorithm,
"publicKeyLength", len(publicKey))
logger.Info(
"WebAuthn credential data collected - ready for blockchain transaction",
"credentialID",
credential.CredentialID,
"username",
credential.Username,
"origin",
credential.Origin,
"algorithm",
credential.Algorithm,
)
return nil
}
// extractOriginFromClientData extracts the origin from client data JSON using centralized WebAuthn parsing
func extractOriginFromClientData(clientDataJSON string) (string, error) {
// Use the centralized WebAuthn client data parser
clientData, err := webauthn.ValidateClientDataJSONFormat(clientDataJSON)
if err != nil {
return "", fmt.Errorf("failed to parse client data: %w", err)
}
if clientData.Origin == "" {
return "", fmt.Errorf("origin not found in client data JSON")
}
return clientData.Origin, nil
}
// extractPublicKeyFromAttestation extracts public key and algorithm from attestation object using centralized WebAuthn parsing
func extractPublicKeyFromAttestation(attestationObject string) ([]byte, int32, error) {
// Use the centralized WebAuthn attestation validation first
if err := webauthn.ValidateAttestationObjectFormat(attestationObject); err != nil {
return nil, 0, fmt.Errorf("invalid attestation object format: %w", err)
}
// Decode the attestation object
attestationBytes, err := base64.RawURLEncoding.DecodeString(attestationObject)
if err != nil {
return nil, 0, fmt.Errorf("failed to decode attestation object: %w", err)
}
// Parse the attestation object using the centralized WebAuthn CBOR parsing
var attestationObj webauthn.AttestationObject
if err := webauthncbor.Unmarshal(attestationBytes, &attestationObj); err != nil {
return nil, 0, fmt.Errorf("failed to unmarshal attestation object: %w", err)
}
// Unmarshal the authenticator data
if err := attestationObj.AuthData.Unmarshal(attestationObj.RawAuthData); err != nil {
return nil, 0, fmt.Errorf("failed to unmarshal authenticator data: %w", err)
}
// Extract the attested credential data
if !attestationObj.AuthData.Flags.HasAttestedCredentialData() {
return nil, 0, fmt.Errorf("attestation object missing attested credential data")
}
publicKey := attestationObj.AuthData.AttData.CredentialPublicKey
if len(publicKey) == 0 {
return nil, 0, fmt.Errorf("no public key found in attested credential data")
}
// Assume ES256 algorithm for now. In the future, this could be extracted
// from the COSE key format in the public key bytes
algorithm := int32(-7) // ES256
return publicKey, algorithm, nil
}
// WebAuthnCredential represents a WebAuthn credential for processing
type WebAuthnCredential struct {
CredentialID string
RawID string
ClientDataJSON string
AttestationObject string
Username string
CreatedAt time.Time
// Extracted fields
Origin string
PublicKey []byte
Algorithm int32
}
// storeWebAuthnCredential stores the WebAuthn credential in the database
func storeWebAuthnCredential(credential *WebAuthnCredential) error {
// Initialize database if not already done
if db == nil {
if err := InitDB(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
}
// Convert WebAuthn credential to database model
storedCredential := &StoredWebAuthnCredential{
CredentialID: credential.CredentialID,
RawID: credential.RawID,
ClientDataJSON: credential.ClientDataJSON,
AttestationObject: credential.AttestationObject,
Username: credential.Username,
Origin: "localhost", // Default for CLI registration
RPID: "localhost",
Algorithm: -7, // ES256 algorithm by default
}
// Store using service
service := NewWebAuthnCredentialService()
return service.Store(storedCredential)
}
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"time"
)
// StoredWebAuthnCredential represents a stored WebAuthn credential in database
type StoredWebAuthnCredential struct {
ID uint `gorm:"primaryKey"`
CredentialID string `gorm:"uniqueIndex;not null"`
RawID string `gorm:"not null"`
ClientDataJSON string `gorm:"type:text;not null"`
AttestationObject string `gorm:"type:text;not null"`
Username string `gorm:"index;not null"`
PublicKey []byte `gorm:"type:blob"`
Algorithm int32 `gorm:"not null"`
Origin string `gorm:"not null"`
RPID string `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// UnsignedTransaction represents an unsigned transaction waiting to be signed
type UnsignedTransaction struct {
ID uint `gorm:"primaryKey"`
TxID string `gorm:"uniqueIndex;not null"`
Username string `gorm:"index;not null"`
TxData []byte `gorm:"type:blob;not null"` // Serialized transaction data
TxType string `gorm:"not null"` // e.g., "MsgRegisterWebAuthnCredential", "MsgCreateRecord"
Description string `gorm:"type:text"`
Status string `gorm:"not null;default:pending"` // pending, signed, broadcast, failed
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ExpiresAt *time.Time
}
// AccountInfo represents DWN wallet account information
type AccountInfo struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex;not null"`
Address string `gorm:"uniqueIndex;not null"`
DID string `gorm:"uniqueIndex"`
PublicKey []byte `gorm:"type:blob"`
EncryptedPrivKey []byte `gorm:"type:blob"` // Encrypted with user's WebAuthn credential
KeyType string `gorm:"not null"` // e.g., "secp256k1", "ed25519"
ChainID string `gorm:"not null"`
AccountNumber uint64 `gorm:"not null"`
Sequence uint64 `gorm:"not null"`
VaultID string `gorm:"index"`
VaultPublicKey []byte `gorm:"type:blob"`
EnclaveID string `gorm:"index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// VaultInfo represents vault metadata and encryption keys
type VaultInfo struct {
ID uint `gorm:"primaryKey"`
VaultID string `gorm:"uniqueIndex;not null"`
Username string `gorm:"index;not null"`
EnclaveID string `gorm:"uniqueIndex;not null"`
PublicKey []byte `gorm:"type:blob;not null"`
EncryptedEnclave []byte `gorm:"type:blob;not null"` // MPC enclave data encrypted
IPFSHash string `gorm:"index"` // IPFS hash for vault data
Status string `gorm:"not null;default:active"` // active, rotated, deprecated
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// SessionInfo represents active WebAuthn sessions
type SessionInfo struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"index;not null"`
SessionID string `gorm:"uniqueIndex;not null"`
Challenge string `gorm:"not null"`
SessionType string `gorm:"not null"` // registration, authentication
Status string `gorm:"not null;default:active"` // active, completed, expired
CreatedAt time.Time `gorm:"autoCreateTime"`
ExpiresAt time.Time `gorm:"not null"`
}
+323
View File
@@ -0,0 +1,323 @@
// Package server provides a spawnable HTTP server for Auth service.
package server
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// Errors
var (
ErrAuthServerAlreadyRunning = errors.New("auth server already running")
ErrAuthServerNotRunning = errors.New("auth server not running")
ErrFailedToStartAuthServer = errors.New("failed to start auth server")
)
// AuthServer is a spawnable HTTP server for Auth service.
type AuthServer struct {
*echo.Echo
Port int
KillChan chan bool
ctx context.Context
cancel context.CancelFunc
sessionStore map[string]string // In-memory session store for WebAuthn challenges
registrationDone chan error // Channel to signal registration completion
credentialData chan *WebAuthnCredential // Channel to pass credential data to CLI
username string // Current username being registered
}
var authServer *AuthServer
// StartAuthServer starts the auth server
func StartAuthServer() error {
if authServer != nil {
return ErrAuthServerAlreadyRunning
}
setupAuthServer()
return authServer.Start()
}
// StartAuthServerWithWebAuthn starts the auth server with WebAuthn support
func StartAuthServerWithWebAuthn(port int, username string, done chan error) error {
if authServer != nil {
return ErrAuthServerAlreadyRunning
}
setupAuthServerWithWebAuthn(port, username, done)
return authServer.Start()
}
// StartAuthServerWithWebAuthnAndCredentialChannel starts auth server with WebAuthn and credential data channel
func StartAuthServerWithWebAuthnAndCredentialChannel(
port int,
username string,
done chan error,
credentialData chan *WebAuthnCredential,
) error {
if authServer != nil {
return ErrAuthServerAlreadyRunning
}
setupAuthServerWithWebAuthnAndCredentialChannel(port, username, done, credentialData)
return authServer.Start()
}
// StartAuthServerForLogin starts the auth server for WebAuthn login
func StartAuthServerForLogin(port int, username string, done chan error) error {
if authServer != nil {
return ErrAuthServerAlreadyRunning
}
setupAuthServerForLogin(port, username, done)
return authServer.Start()
}
// StopAuthServer stops the auth server
func StopAuthServer() error {
if authServer == nil {
return ErrAuthServerNotRunning
}
return authServer.Stop()
}
func (s *AuthServer) Start() error {
// Setup signal context
s.ctx, s.cancel = signal.NotifyContext(context.Background(), os.Interrupt)
// Start server in goroutine
go func() {
if err := s.Echo.Start(fmt.Sprintf(":%d", s.Port)); err != nil &&
err != http.ErrServerClosed {
s.Logger.Fatal("shutting down the server")
}
}()
// Start kill signal handler in another goroutine
go s.HandleKillSignal()
return nil
}
func (s *AuthServer) Stop() error {
// Cancel the signal context to trigger shutdown
if s.cancel != nil {
s.cancel()
}
// Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Gracefully shutdown the server
if err := s.Shutdown(ctx); err != nil {
s.Logger.Fatal(err)
return err
}
// Clean up
destroyAuthServer()
return nil
}
func (s *AuthServer) HandleKillSignal() {
select {
case <-s.KillChan:
// Manual stop via KillChan
s.Stop()
case <-s.ctx.Done():
// OS interrupt signal received
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
s.Logger.Fatal(err)
}
destroyAuthServer()
}
}
// ╭───────────────────────────────────────────────────────────╮
// │ Server Config │
// ╰───────────────────────────────────────────────────────────╯
func setupRoutes(e *echo.Echo) {
// Basic routes
e.GET("/", HandleIndex)
e.GET("/health", HandleHealth)
e.POST("/login", HandleLogin)
// WebAuthn registration routes
e.GET("/register", HandleWebAuthnRegister)
e.GET("/begin-register", HandleBeginRegister) // GET for fetching options
e.POST("/begin-register", HandleBeginRegister) // POST also supported for client compatibility
e.POST("/finish-register", HandleFinishRegister)
}
// setupMiddleware configures server middleware
func setupMiddleware(e *echo.Echo) {
// CORS middleware for browser compatibility
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"http://localhost:*", "https://localhost:*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"*"},
AllowCredentials: true,
}))
// Security middleware
e.Use(middleware.Secure())
e.Use(middleware.RequestID())
// Disable HTTP request logging for cleaner CLI output
// e.Use(middleware.Logger())
e.Use(middleware.Recover())
}
// destroyAuthServer destroys the auth server
func destroyAuthServer() {
authServer = nil
}
// setupAuthServer sets up the auth server
func setupAuthServer() {
authServer = &AuthServer{
Echo: echo.New(),
Port: 8080,
KillChan: make(chan bool),
}
// Disable Echo framework logging for cleaner CLI output
authServer.HideBanner = true
authServer.HidePort = true
setupMiddleware(authServer.Echo)
setupRoutes(authServer.Echo)
}
// setupAuthServerWithWebAuthn sets up the auth server with WebAuthn context
func setupAuthServerWithWebAuthn(port int, username string, done chan error) {
// Initialize database for WebAuthn credential storage
_ = InitDB() // Errors handled gracefully in storeWebAuthnCredential
authServer = &AuthServer{
Echo: echo.New(),
Port: port,
KillChan: make(chan bool),
sessionStore: make(map[string]string),
registrationDone: done,
username: username,
}
// Disable Echo framework logging for cleaner CLI output
authServer.HideBanner = true
authServer.HidePort = true
setupMiddleware(authServer.Echo)
setupRoutes(authServer.Echo)
// Set up automatic server shutdown after 15 seconds as failsafe
go func() {
time.Sleep(15 * time.Second)
if authServer != nil {
logger := authServer.Logger
logger.Warn("Auto-shutting down auth server after 15 second timeout")
select {
case authServer.KillChan <- true:
logger.Info("Server shutdown signal sent via KillChan")
default:
logger.Warn("KillChan full, server may already be shutting down")
}
}
}()
}
// setupAuthServerWithWebAuthnAndCredentialChannel sets up auth server with WebAuthn and credential channel
func setupAuthServerWithWebAuthnAndCredentialChannel(
port int,
username string,
done chan error,
credentialData chan *WebAuthnCredential,
) {
// Initialize database for WebAuthn credential storage
_ = InitDB() // Errors handled gracefully in storeWebAuthnCredential
e := echo.New()
e.HideBanner = true
e.HidePort = true
authServer = &AuthServer{
Echo: e,
Port: port,
KillChan: make(chan bool),
sessionStore: make(map[string]string),
registrationDone: done,
credentialData: credentialData,
username: username,
}
// Disable Echo framework logging for cleaner CLI output
authServer.HideBanner = true
authServer.HidePort = true
setupMiddleware(authServer.Echo)
setupRoutes(authServer.Echo)
// Set up automatic server shutdown after 15 seconds as failsafe
go func() {
time.Sleep(15 * time.Second)
if authServer != nil {
logger := authServer.Logger
logger.Warn("Auto-shutting down auth server after 15 second timeout")
select {
case authServer.KillChan <- true:
logger.Info("Server shutdown signal sent via KillChan")
default:
logger.Warn("KillChan full, server may already be shutting down")
}
}
}()
}
// setupAuthServerForLogin sets up the auth server for WebAuthn login
func setupAuthServerForLogin(port int, username string, done chan error) {
// Initialize database for WebAuthn credential verification
_ = InitDB() // Errors handled gracefully in login handlers
authServer = &AuthServer{
Echo: echo.New(),
Port: port,
KillChan: make(chan bool),
sessionStore: make(map[string]string),
registrationDone: done,
username: username,
}
// Disable Echo framework logging for cleaner CLI output
authServer.HideBanner = true
authServer.HidePort = true
setupMiddleware(authServer.Echo)
setupLoginRoutes(authServer.Echo)
// Set up automatic server shutdown after 45 seconds as failsafe (longer for login)
go func() {
time.Sleep(45 * time.Second)
if authServer != nil {
logger := authServer.Logger
logger.Warn("Auto-shutting down login auth server after 45 second timeout")
select {
case authServer.KillChan <- true:
logger.Info("Login server shutdown signal sent via KillChan")
default:
logger.Warn("KillChan full, login server may already be shutting down")
}
}
}()
}
// setupLoginRoutes configures routes specifically for login flow
func setupLoginRoutes(e *echo.Echo) {
// Basic routes
e.GET("/", HandleIndex)
e.GET("/health", HandleHealth)
// WebAuthn login routes
e.GET("/login", HandleWebAuthnLogin)
e.GET("/begin-login", HandleBeginLogin)
e.POST("/begin-login", HandleBeginLogin) // POST also supported for client compatibility
e.POST("/finish-login", HandleFinishLogin)
e.POST("/login/verify", HandleFinishLogin) // Alternative endpoint for client compatibility
}
+477
View File
@@ -0,0 +1,477 @@
package server
// webAuthnRegistrationHTML contains the HTML template for WebAuthn registration
const webAuthnRegistrationHTML = `<!DOCTYPE html>
<html class="dark">
<head>
<title>Sonr Local Registration</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style>
:root {
--sonr-primary: #17c2ff;
--sonr-primary-hover: #0ea5e9;
--sonr-primary-glow: rgba(23, 194, 255, 0.3);
}
body {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
.glow {
box-shadow: 0 0 20px var(--sonr-primary-glow);
}
.pulse-primary {
animation: pulse-primary 2s ease-in-out infinite;
}
@keyframes pulse-primary {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center bg-slate-900 text-white font-sans">
<div class="bg-slate-800 rounded-xl p-8 shadow-2xl border border-slate-700 max-w-md w-full mx-4 glow">
<div class="text-center space-y-6">
<!-- Header -->
<div class="space-y-2">
<h1 class="text-3xl font-bold text-white">Sonr Registration</h1>
<div class="h-1 bg-gradient-to-r from-[#17c2ff] to-[#0ea5e9] rounded-full mx-auto w-24"></div>
</div>
<!-- User Info -->
<div class="bg-slate-700 rounded-lg p-4 border border-slate-600">
<p class="text-slate-300 text-sm font-medium mb-1">Registering User</p>
<p id="username-display" class="text-[#17c2ff] text-xl font-bold">{{.Username}}</p>
</div>
<!-- Status Section -->
<div class="space-y-4">
<div id="status" class="text-[#17c2ff] font-semibold text-lg pulse-primary">
Initializing WebAuthn registration...
</div>
<div id="instructions" class="text-slate-300 text-sm leading-relaxed">
Please follow your browser and authenticator prompts.
</div>
<!-- Progress Indicator -->
<div class="w-full bg-slate-700 rounded-full h-2">
<div id="progress" class="bg-gradient-to-r from-[#17c2ff] to-[#0ea5e9] h-2 rounded-full w-0 transition-all duration-500"></div>
</div>
<!-- Timeout Display -->
<div id="timeout" class="text-slate-400 text-xs font-mono"></div>
</div>
</div>
</div>
<!-- Load SimpleWebAuthn for WebAuthn operations -->
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
<!-- Load @sonr.io/es for presets and utilities -->
<script type="module" src="https://unpkg.com/@sonr.io/es@latest/dist/autoloader.js"></script>
<script>
// Support both username and identifier parameters
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('identifier') || urlParams.get('username') || '{{.Username}}';
const rpId = '{{.RPID}}';
const API_URL = window.location.origin; // Use current origin as API URL
// Update the username display if it's from URL params
if (urlParams.get('identifier') || urlParams.get('username')) {
document.getElementById('username-display').textContent = username;
}
function updateStatus(message, type = 'info') {
const statusEl = document.getElementById('status');
const progressEl = document.getElementById('progress');
statusEl.textContent = message;
// Remove all existing classes and add new ones based on type
statusEl.className = 'font-semibold text-lg';
switch(type) {
case 'success':
statusEl.className += ' text-green-400';
statusEl.classList.remove('pulse-primary');
progressEl.style.width = '100%';
progressEl.className = 'bg-gradient-to-r from-green-400 to-green-500 h-2 rounded-full transition-all duration-500';
break;
case 'error':
statusEl.className += ' text-red-400';
statusEl.classList.remove('pulse-primary');
progressEl.style.width = '100%';
progressEl.className = 'bg-gradient-to-r from-red-400 to-red-500 h-2 rounded-full transition-all duration-500';
break;
case 'processing':
statusEl.className += ' text-[#17c2ff] pulse-primary';
progressEl.style.width = '75%';
break;
default: // info
statusEl.className += ' text-[#17c2ff] pulse-primary';
progressEl.style.width = '25%';
}
}
function updateInstructions(message) {
const instructionsEl = document.getElementById('instructions');
instructionsEl.textContent = message;
instructionsEl.className = 'text-slate-300 text-sm leading-relaxed';
}
function updateTimeout(seconds) {
const timeoutEl = document.getElementById('timeout');
if (seconds > 0) {
timeoutEl.textContent = 'Timeout in ' + seconds + 's';
timeoutEl.className = 'text-slate-400 text-xs font-mono';
} else {
timeoutEl.textContent = 'Registration timed out';
timeoutEl.className = 'text-red-400 text-xs font-mono';
}
}
// Start countdown timer
let timeoutSeconds = 30;
const countdownInterval = setInterval(() => {
updateTimeout(timeoutSeconds);
timeoutSeconds--;
if (timeoutSeconds < 0) {
clearInterval(countdownInterval);
updateStatus('Registration timed out', 'error');
updateInstructions('Please return to the CLI and try again.');
}
}, 1000);
async function startRegistration() {
try {
// Check if SimpleWebAuthn is loaded
if (!window.SimpleWebAuthnBrowser) {
throw new Error('Failed to load WebAuthn library');
}
// Check WebAuthn support
const isSupported = window.SimpleWebAuthnBrowser.browserSupportsWebAuthn();
if (!isSupported) {
throw new Error('WebAuthn is not supported in this browser. Please use a modern browser like Chrome, Firefox, Safari, or Edge.');
}
// Check if platform authenticator is available
const isAvailable = await window.SimpleWebAuthnBrowser.platformAuthenticatorIsAvailable();
if (!isAvailable) {
updateStatus('Platform authenticator not available', 'info');
updateInstructions('You can use a security key or your phone via QR code to create a passkey.');
}
updateStatus('Initializing passkey registration...', 'info');
updateInstructions('Preparing your authentication request...');
// Hybrid approach: Use Sonr presets but local server endpoints
updateStatus('Initializing passkey registration...', 'info');
updateInstructions('Preparing your authentication request...');
// Step 1: Get registration options from local server
const optionsResponse = await fetch(API_URL + '/begin-register?username=' + encodeURIComponent(username));
if (!optionsResponse.ok) {
const error = await optionsResponse.json();
throw new Error(error.error || 'Failed to get registration options');
}
const registrationOptions = await optionsResponse.json();
console.log('Registration options:', registrationOptions);
updateStatus('Please interact with your authenticator...', 'processing');
updateInstructions('You can use: 1) This device\'s biometrics, 2) A security key, or 3) Your phone via QR code (if prompted)');
// Step 2: Use SimpleWebAuthn to create credential
const credential = await window.SimpleWebAuthnBrowser.startRegistration(registrationOptions);
console.log('Created credential:', credential);
// Step 3: Send credential to local server to complete registration
updateStatus('Completing registration...', 'processing');
const finishResponse = await fetch(API_URL + '/finish-register?username=' + encodeURIComponent(username), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credential)
});
if (!finishResponse.ok) {
const error = await finishResponse.json();
throw new Error(error.error || 'Failed to complete registration');
}
const result = await finishResponse.json();
console.log('Registration result:', result);
// Clear the countdown timer
clearInterval(countdownInterval);
if (result.success) {
updateStatus('Registration successful!', 'success');
updateInstructions('Your passkey has been registered. Credential ID: ' + (result.credentialId || 'Created') + '. You can now close this window and return to the CLI.');
updateTimeout(0);
// Store credential ID if provided
if (result.credentialId) {
sessionStorage.setItem('sonr_credential_id', result.credentialId);
}
} else {
throw new Error(result.error || 'Registration failed');
}
} catch (error) {
// Clear the countdown timer
clearInterval(countdownInterval);
console.error('Registration failed:', error);
// Provide more specific error messages
let errorMessage = error.message;
if (error.name === 'NotAllowedError') {
errorMessage = 'Registration was cancelled or not allowed';
} else if (error.name === 'InvalidStateError') {
errorMessage = 'An authenticator is already registered';
} else if (error.name === 'NotSupportedError') {
errorMessage = 'This authenticator is not supported';
}
updateStatus('Registration failed', 'error');
updateInstructions(errorMessage);
updateTimeout(0);
}
}
// Start registration when page loads
window.addEventListener('load', () => {
// Add a small delay to ensure all resources are loaded
setTimeout(startRegistration, 500);
});
</script>
</body>
</html>`
// webAuthnLoginHTML contains the HTML template for WebAuthn login
const webAuthnLoginHTML = `<!DOCTYPE html>
<html class="dark">
<head>
<title>Sonr Local Login</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style>
:root {
--sonr-primary: #17c2ff;
--sonr-primary-hover: #0ea5e9;
}
</style>
</head>
<body class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div class="bg-gray-800 p-8 rounded-2xl shadow-2xl max-w-md w-full">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-400 rounded-full mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h1 class="text-3xl font-bold text-white mb-2">Welcome Back to Sonr</h1>
<p class="text-gray-400">Authenticating as: <span id="login-username-display" class="font-semibold text-cyan-400">{{.Username}}</span></p>
</div>
<div id="status-container" class="mb-6">
<div id="status" class="p-4 rounded-lg bg-blue-900/50 text-blue-300 text-sm font-medium">
Initializing WebAuthn authentication...
</div>
</div>
<div id="instructions" class="text-center text-gray-300 mb-6">
Use your passkey or security key to authenticate.
</div>
<div id="timeout-container" class="text-center text-sm text-gray-500">
<span id="timeout-text"></span>
</div>
</div>
<!-- Load SimpleWebAuthn for WebAuthn operations -->
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
<!-- Load @sonr.io/es for presets and utilities -->
<script type="module" src="https://unpkg.com/@sonr.io/es@latest/dist/autoloader.js"></script>
<script>
const TIMEOUT_SECONDS = 30;
// Support both username and identifier parameters
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('identifier') || urlParams.get('username') || "{{.Username}}";
const rpId = "{{.RPID}}";
const rpName = "{{.RPName}}";
const API_URL = window.location.origin; // Use current origin as API URL
// Update the username display if it's from URL params
if (urlParams.get('identifier') || urlParams.get('username')) {
document.getElementById('login-username-display').textContent = username;
}
function updateStatus(message, type = 'info') {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = 'p-4 rounded-lg text-sm font-medium ';
if (type === 'success') {
statusEl.className += 'bg-green-900/50 text-green-300';
} else if (type === 'error') {
statusEl.className += 'bg-red-900/50 text-red-300';
} else {
statusEl.className += 'bg-blue-900/50 text-blue-300';
}
}
function updateInstructions(text) {
document.getElementById('instructions').textContent = text;
}
function updateTimeout(seconds) {
const timeoutEl = document.getElementById('timeout-text');
if (seconds > 0) {
timeoutEl.textContent = 'Authentication will timeout in ' + seconds + ' seconds';
} else {
timeoutEl.textContent = '';
}
}
async function startLogin() {
let countdownInterval;
let remainingSeconds = TIMEOUT_SECONDS;
try {
// Wait for Sonr to be ready
await new Promise((resolve) => {
if (window.Sonr && window.Sonr.initialized) {
resolve();
} else {
window.addEventListener('sonr:ready', resolve);
// Timeout after 5 seconds
setTimeout(() => {
if (window.Sonr) resolve();
else throw new Error('Failed to load Sonr library');
}, 5000);
}
});
if (!window.Sonr) {
throw new Error('Failed to load Sonr authentication library');
}
// Check WebAuthn support
const support = await window.Sonr.webauthn.checkSupport();
if (!support.supported) {
throw new Error('WebAuthn is not supported in this browser. Please use a modern browser like Chrome, Firefox, Safari, or Edge.');
}
if (!support.platformAuthenticator) {
updateStatus('Platform authenticator not available', 'info');
updateInstructions('You can still use a security key or phone-based passkey to authenticate.');
}
updateStatus('Initializing passkey authentication...', 'info');
updateInstructions('Preparing your authentication request...');
// Start countdown timer
countdownInterval = setInterval(() => {
remainingSeconds--;
updateTimeout(remainingSeconds);
if (remainingSeconds <= 0) {
clearInterval(countdownInterval);
updateStatus('Authentication timed out', 'error');
updateInstructions('Please refresh the page to try again.');
}
}, 1000);
updateTimeout(remainingSeconds);
// Hybrid approach: Use Sonr presets but local server endpoints
updateStatus('Preparing authentication...', 'info');
// Step 1: Get authentication options from local server
const optionsResponse = await fetch(API_URL + '/begin-login?username=' + encodeURIComponent(username));
if (!optionsResponse.ok) {
const error = await optionsResponse.json();
throw new Error(error.error || 'Failed to get authentication options');
}
const authOptions = await optionsResponse.json();
console.log('Authentication options:', authOptions);
updateStatus('Waiting for your passkey authentication...', 'info');
updateInstructions('Use your saved passkey from: 1) This device, 2) A security key, or 3) Your phone');
// Step 2: Use SimpleWebAuthn to authenticate
const credential = await window.SimpleWebAuthnBrowser.startAuthentication(authOptions);
console.log('Authentication credential:', credential);
// Step 3: Send credential to local server to complete authentication
updateStatus('Verifying authentication...', 'info');
const finishResponse = await fetch(API_URL + '/finish-login?username=' + encodeURIComponent(username), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credential)
});
if (!finishResponse.ok) {
const error = await finishResponse.json();
throw new Error(error.error || 'Failed to complete authentication');
}
const result = await finishResponse.json();
console.log('Authentication result:', result);
// Clear the countdown timer
clearInterval(countdownInterval);
if (result.success) {
updateStatus('Authentication successful!', 'success');
updateInstructions('Welcome back! Credential ID: ' + (result.credentialId || 'Authenticated') + '. You can close this window and return to the CLI.');
updateTimeout(0);
// Store credential ID if provided
if (result.credentialId) {
sessionStorage.setItem('sonr_credential_id', result.credentialId);
}
} else {
throw new Error(result.error || 'Authentication failed');
}
} catch (error) {
// Clear the countdown timer
clearInterval(countdownInterval);
console.error('Authentication failed:', error);
// Provide more specific error messages
let errorMessage = error.message;
if (error.name === 'NotAllowedError') {
errorMessage = 'Authentication was cancelled or not allowed';
} else if (error.name === 'InvalidStateError') {
errorMessage = 'No matching credential found';
} else if (error.name === 'NotSupportedError') {
errorMessage = 'This authenticator is not supported';
}
updateStatus('Authentication failed', 'error');
updateInstructions(errorMessage);
updateTimeout(0);
}
}
// Start login when page loads
window.addEventListener('load', () => {
// Add a small delay to ensure all resources are loaded
setTimeout(startLogin, 500);
});
</script>
</body>
</html>`