authorize.go raw

   1  package handler
   2  
   3  import (
   4  	"html/template"
   5  	"log"
   6  	"net/http"
   7  )
   8  
   9  const loginPageHTML = `<!DOCTYPE html>
  10  <html lang="en">
  11  <head>
  12      <meta charset="UTF-8">
  13      <meta name="viewport" content="width=device-width, initial-scale=1.0">
  14      <title>Login with Nostr</title>
  15      <style>
  16          * {
  17              box-sizing: border-box;
  18          }
  19          body {
  20              font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  21              background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
  22              min-height: 100vh;
  23              display: flex;
  24              align-items: center;
  25              justify-content: center;
  26              margin: 0;
  27              padding: 20px;
  28          }
  29          .container {
  30              background: rgba(255, 255, 255, 0.05);
  31              backdrop-filter: blur(10px);
  32              border-radius: 20px;
  33              padding: 40px;
  34              max-width: 400px;
  35              width: 100%;
  36              text-align: center;
  37              border: 1px solid rgba(255, 255, 255, 0.1);
  38          }
  39          h1 {
  40              color: #fff;
  41              margin-bottom: 10px;
  42              font-size: 24px;
  43          }
  44          .subtitle {
  45              color: rgba(255, 255, 255, 0.6);
  46              margin-bottom: 30px;
  47              font-size: 14px;
  48          }
  49          .nostr-logo {
  50              font-size: 64px;
  51              margin-bottom: 20px;
  52          }
  53          button {
  54              background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
  55              color: white;
  56              border: none;
  57              padding: 16px 32px;
  58              font-size: 16px;
  59              border-radius: 12px;
  60              cursor: pointer;
  61              width: 100%;
  62              font-weight: 600;
  63              transition: transform 0.2s, box-shadow 0.2s;
  64          }
  65          button:hover:not(:disabled) {
  66              transform: translateY(-2px);
  67              box-shadow: 0 10px 40px rgba(139, 92, 246, 0.3);
  68          }
  69          button:disabled {
  70              opacity: 0.5;
  71              cursor: not-allowed;
  72          }
  73          .status {
  74              margin-top: 20px;
  75              padding: 12px;
  76              border-radius: 8px;
  77              font-size: 14px;
  78          }
  79          .status.error {
  80              background: rgba(239, 68, 68, 0.2);
  81              color: #fca5a5;
  82              border: 1px solid rgba(239, 68, 68, 0.3);
  83          }
  84          .status.success {
  85              background: rgba(34, 197, 94, 0.2);
  86              color: #86efac;
  87              border: 1px solid rgba(34, 197, 94, 0.3);
  88          }
  89          .status.info {
  90              background: rgba(59, 130, 246, 0.2);
  91              color: #93c5fd;
  92              border: 1px solid rgba(59, 130, 246, 0.3);
  93          }
  94          .hidden {
  95              display: none;
  96          }
  97          .no-extension {
  98              color: rgba(255, 255, 255, 0.8);
  99          }
 100          .no-extension a {
 101              color: #8b5cf6;
 102              text-decoration: none;
 103          }
 104          .no-extension a:hover {
 105              text-decoration: underline;
 106          }
 107          .extension-list {
 108              margin-top: 15px;
 109              text-align: left;
 110              padding-left: 20px;
 111          }
 112          .extension-list li {
 113              margin: 8px 0;
 114              color: rgba(255, 255, 255, 0.7);
 115          }
 116          .pubkey {
 117              font-family: monospace;
 118              font-size: 12px;
 119              word-break: break-all;
 120              background: rgba(0, 0, 0, 0.3);
 121              padding: 8px;
 122              border-radius: 6px;
 123              margin-top: 10px;
 124          }
 125      </style>
 126  </head>
 127  <body>
 128      <div class="container">
 129          <div class="nostr-logo">&#x1F5DD;</div>
 130          <h1>Login with Nostr</h1>
 131          <p class="subtitle">Sign in using your Nostr identity</p>
 132  
 133          <div id="no-extension" class="no-extension hidden">
 134              <p>No Nostr extension detected. Please install one of these:</p>
 135              <ul class="extension-list">
 136                  <li><a href="https://getalby.com" target="_blank">Alby</a></li>
 137                  <li><a href="https://github.com/nickytonline/nos2x" target="_blank">nos2x</a></li>
 138                  <li><a href="https://github.com/nickytonline/flamingo" target="_blank">Flamingo</a></li>
 139              </ul>
 140          </div>
 141  
 142          <div id="login-section">
 143              <button id="login-btn" disabled>Checking for extension...</button>
 144              <div id="status" class="status hidden"></div>
 145          </div>
 146      </div>
 147  
 148      <script>
 149          const challenge = "{{.Challenge}}";
 150          const verifyURL = "{{.VerifyURL}}";
 151  
 152          const loginBtn = document.getElementById('login-btn');
 153          const statusDiv = document.getElementById('status');
 154          const noExtensionDiv = document.getElementById('no-extension');
 155          const loginSection = document.getElementById('login-section');
 156  
 157          function showStatus(message, type) {
 158              statusDiv.textContent = message;
 159              statusDiv.className = 'status ' + type;
 160              statusDiv.classList.remove('hidden');
 161          }
 162  
 163          function hideStatus() {
 164              statusDiv.classList.add('hidden');
 165          }
 166  
 167          async function checkExtension() {
 168              // Wait a bit for extension to inject
 169              await new Promise(resolve => setTimeout(resolve, 100));
 170  
 171              if (typeof window.nostr === 'undefined') {
 172                  noExtensionDiv.classList.remove('hidden');
 173                  loginBtn.textContent = 'No extension found';
 174                  return false;
 175              }
 176  
 177              loginBtn.disabled = false;
 178              loginBtn.textContent = 'Sign in with Nostr';
 179              return true;
 180          }
 181  
 182          async function login() {
 183              loginBtn.disabled = true;
 184              loginBtn.textContent = 'Signing...';
 185              hideStatus();
 186  
 187              try {
 188                  // Get public key
 189                  const pubkey = await window.nostr.getPublicKey();
 190                  showStatus('Got public key, signing challenge...', 'info');
 191  
 192                  // Create event to sign (NIP-98 style)
 193                  const event = {
 194                      kind: 27235,
 195                      created_at: Math.floor(Date.now() / 1000),
 196                      tags: [
 197                          ['u', window.location.href],
 198                          ['method', 'GET'],
 199                          ['challenge', challenge]
 200                      ],
 201                      content: ''
 202                  };
 203  
 204                  // Sign the event
 205                  const signedEvent = await window.nostr.signEvent(event);
 206                  showStatus('Signed! Verifying with server...', 'info');
 207  
 208                  // Submit to server
 209                  const response = await fetch(verifyURL, {
 210                      method: 'POST',
 211                      headers: {
 212                          'Content-Type': 'application/json'
 213                      },
 214                      body: JSON.stringify(signedEvent)
 215                  });
 216  
 217                  const result = await response.json();
 218  
 219                  if (result.redirect_url) {
 220                      showStatus('Success! Redirecting...', 'success');
 221                      window.location.href = result.redirect_url;
 222                  } else if (result.error) {
 223                      showStatus('Error: ' + result.error, 'error');
 224                      loginBtn.disabled = false;
 225                      loginBtn.textContent = 'Try again';
 226                  }
 227              } catch (err) {
 228                  console.error('Login error:', err);
 229                  showStatus('Error: ' + err.message, 'error');
 230                  loginBtn.disabled = false;
 231                  loginBtn.textContent = 'Try again';
 232              }
 233          }
 234  
 235          loginBtn.addEventListener('click', login);
 236          checkExtension();
 237      </script>
 238  </body>
 239  </html>`
 240  
 241  func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
 242  	// Extract OAuth2 parameters
 243  	clientID := r.URL.Query().Get("client_id")
 244  	redirectURI := r.URL.Query().Get("redirect_uri")
 245  	state := r.URL.Query().Get("state")
 246  	responseType := r.URL.Query().Get("response_type")
 247  
 248  	// Validate required parameters
 249  	if clientID == "" {
 250  		http.Error(w, "missing client_id", http.StatusBadRequest)
 251  		return
 252  	}
 253  	if redirectURI == "" {
 254  		http.Error(w, "missing redirect_uri", http.StatusBadRequest)
 255  		return
 256  	}
 257  	if responseType != "code" {
 258  		http.Error(w, "unsupported response_type, must be 'code'", http.StatusBadRequest)
 259  		return
 260  	}
 261  
 262  	// Validate client and redirect URI
 263  	if !h.cfg.ValidateRedirectURI(clientID, redirectURI) {
 264  		http.Error(w, "invalid client_id or redirect_uri", http.StatusBadRequest)
 265  		return
 266  	}
 267  
 268  	// Create challenge
 269  	challenge, err := h.store.CreateChallenge(clientID, state, redirectURI, h.cfg.Nostr.ChallengeTTL)
 270  	if err != nil {
 271  		log.Printf("failed to create challenge: %v", err)
 272  		http.Error(w, "internal error", http.StatusInternalServerError)
 273  		return
 274  	}
 275  
 276  	// Render login page
 277  	tmpl, err := template.New("login").Parse(loginPageHTML)
 278  	if err != nil {
 279  		log.Printf("failed to parse template: %v", err)
 280  		http.Error(w, "internal error", http.StatusInternalServerError)
 281  		return
 282  	}
 283  
 284  	data := struct {
 285  		Challenge string
 286  		VerifyURL string
 287  	}{
 288  		Challenge: challenge.Nonce,
 289  		VerifyURL: h.cfg.Server.BaseURL + "/verify",
 290  	}
 291  
 292  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 293  	if err := tmpl.Execute(w, data); err != nil {
 294  		log.Printf("failed to execute template: %v", err)
 295  	}
 296  }
 297