* 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
+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>`