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">🗝</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