@qrauth/web-components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base.d.ts +30 -0
- package/dist/base.js +148 -0
- package/dist/ephemeral.d.ts +61 -0
- package/dist/ephemeral.js +727 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/login.d.ts +60 -0
- package/dist/login.js +769 -0
- package/dist/qrauth-components.esm.js +1177 -0
- package/dist/qrauth-components.esm.js.map +7 -0
- package/dist/qrauth-components.js +1177 -0
- package/dist/qrauth-components.js.map +7 -0
- package/dist/twofa.d.ts +51 -0
- package/dist/twofa.js +625 -0
- package/package.json +50 -0
package/dist/twofa.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { QRAuthElement } from './base.js';
|
|
2
|
+
// Same shield SVG as login component
|
|
3
|
+
const SHIELD_SVG = `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
4
|
+
<path d="M48 24 H152 Q166 24 166 38 V108 Q166 156 100 182 Q34 156 34 108 V38 Q34 24 48 24 Z"
|
|
5
|
+
fill="none" stroke="#fff" stroke-width="5"/>
|
|
6
|
+
<g fill="#fff" opacity="0.2">
|
|
7
|
+
<rect x="52" y="40" width="18" height="18" rx="3"/>
|
|
8
|
+
<rect x="78" y="40" width="18" height="18" rx="3"/>
|
|
9
|
+
<rect x="130" y="40" width="18" height="18" rx="3"/>
|
|
10
|
+
<rect x="52" y="66" width="18" height="18" rx="3"/>
|
|
11
|
+
<rect x="104" y="66" width="18" height="18" rx="3"/>
|
|
12
|
+
<rect x="78" y="92" width="18" height="18" rx="3"/>
|
|
13
|
+
<rect x="130" y="92" width="18" height="18" rx="3"/>
|
|
14
|
+
<rect x="52" y="118" width="18" height="18" rx="3"/>
|
|
15
|
+
<rect x="104" y="118" width="18" height="18" rx="3"/>
|
|
16
|
+
</g>
|
|
17
|
+
<path d="M62 104 L88 134 L144 64"
|
|
18
|
+
fill="none" stroke="#fff" stroke-width="15"
|
|
19
|
+
stroke-linecap="round" stroke-linejoin="round"/>
|
|
20
|
+
</svg>`;
|
|
21
|
+
// Check icon for success state
|
|
22
|
+
const CHECK_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
23
|
+
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" fill="currentColor"/>
|
|
24
|
+
</svg>`;
|
|
25
|
+
// Spinner SVG
|
|
26
|
+
const SPINNER_SVG = `<svg class="spinner" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
27
|
+
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2.5"
|
|
28
|
+
stroke-dasharray="31.416" stroke-dashoffset="10" stroke-linecap="round"/>
|
|
29
|
+
</svg>`;
|
|
30
|
+
/**
|
|
31
|
+
* <qrauth-2fa> — Drop-in second-factor authentication component.
|
|
32
|
+
*
|
|
33
|
+
* After the user completes primary login (e.g. password), render this component
|
|
34
|
+
* to perform QR-based step-up verification. Always displays inline — no button/modal mode.
|
|
35
|
+
*
|
|
36
|
+
* Attributes:
|
|
37
|
+
* tenant — Your QRAuth App client ID (required)
|
|
38
|
+
* theme — "light" (default) | "dark"
|
|
39
|
+
* base-url — API base URL (default: https://qrauth.io)
|
|
40
|
+
* session-token — JWT from the primary authentication (required for context)
|
|
41
|
+
* scopes — Space-separated scopes (default: "identity")
|
|
42
|
+
* auto-start — Boolean attribute; if present, starts 2FA immediately on connect
|
|
43
|
+
*
|
|
44
|
+
* Events (bubbles + composed, cross shadow-DOM):
|
|
45
|
+
* qrauth:verified — detail: { sessionId, user, signature }
|
|
46
|
+
* qrauth:denied — fired when the user denies on their device
|
|
47
|
+
* qrauth:expired — fired when the session times out
|
|
48
|
+
* qrauth:error — detail: { message }
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* <qrauth-2fa tenant="qrauth_app_xxx" session-token="eyJ..." auto-start></qrauth-2fa>
|
|
52
|
+
* <qrauth-2fa tenant="qrauth_app_xxx" scopes="identity payments"></qrauth-2fa>
|
|
53
|
+
*/
|
|
54
|
+
export class QRAuthTwoFA extends QRAuthElement {
|
|
55
|
+
static get observedAttributes() {
|
|
56
|
+
return [...super.observedAttributes, 'session-token', 'scopes', 'auto-start'];
|
|
57
|
+
}
|
|
58
|
+
_sessionId = null;
|
|
59
|
+
_codeVerifier = null;
|
|
60
|
+
_status = 'idle';
|
|
61
|
+
_expiresAt = null;
|
|
62
|
+
_timerInterval = null;
|
|
63
|
+
_pollTimeout = null;
|
|
64
|
+
get sessionToken() { return this.getAttribute('session-token'); }
|
|
65
|
+
get scopes() { return this.getAttribute('scopes') || 'identity'; }
|
|
66
|
+
get autoStart() { return this.hasAttribute('auto-start'); }
|
|
67
|
+
// ---- Lifecycle -----------------------------------------------------------
|
|
68
|
+
connectedCallback() {
|
|
69
|
+
super.connectedCallback(); // calls render()
|
|
70
|
+
if (this.autoStart) {
|
|
71
|
+
this._startAuth();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
disconnectedCallback() {
|
|
75
|
+
super.disconnectedCallback();
|
|
76
|
+
this._clearTimers();
|
|
77
|
+
}
|
|
78
|
+
// ---- Render --------------------------------------------------------------
|
|
79
|
+
render() {
|
|
80
|
+
this.shadow.innerHTML = `
|
|
81
|
+
<style>
|
|
82
|
+
${this.getBaseStyles()}
|
|
83
|
+
:host { display: block; }
|
|
84
|
+
|
|
85
|
+
.container {
|
|
86
|
+
background: var(--_bg);
|
|
87
|
+
border: 1px solid var(--_border);
|
|
88
|
+
border-radius: 16px;
|
|
89
|
+
padding: 24px 20px 18px;
|
|
90
|
+
text-align: center;
|
|
91
|
+
max-width: 320px;
|
|
92
|
+
margin: 0 auto;
|
|
93
|
+
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Step indicator */
|
|
97
|
+
.step-label {
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: 6px;
|
|
101
|
+
font-size: 11px;
|
|
102
|
+
font-weight: 600;
|
|
103
|
+
letter-spacing: 0.06em;
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
color: var(--_primary);
|
|
106
|
+
background: rgba(0,167,111,0.08);
|
|
107
|
+
border-radius: 20px;
|
|
108
|
+
padding: 4px 10px;
|
|
109
|
+
margin-bottom: 14px;
|
|
110
|
+
}
|
|
111
|
+
.step-dot {
|
|
112
|
+
width: 6px;
|
|
113
|
+
height: 6px;
|
|
114
|
+
border-radius: 50%;
|
|
115
|
+
background: var(--_primary);
|
|
116
|
+
flex-shrink: 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Idle start button */
|
|
120
|
+
.start-btn {
|
|
121
|
+
display: inline-flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
gap: 10px;
|
|
124
|
+
padding: 11px 22px;
|
|
125
|
+
background: var(--_btn-bg);
|
|
126
|
+
color: #fff;
|
|
127
|
+
border: none;
|
|
128
|
+
border-radius: var(--_radius);
|
|
129
|
+
font-size: 14px;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
|
|
133
|
+
font-family: inherit;
|
|
134
|
+
width: 100%;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
margin-top: 4px;
|
|
137
|
+
}
|
|
138
|
+
.start-btn:hover:not(:disabled) {
|
|
139
|
+
background: var(--_btn-hover);
|
|
140
|
+
transform: translateY(-1px);
|
|
141
|
+
box-shadow: 0 4px 12px rgba(27,42,74,0.3);
|
|
142
|
+
}
|
|
143
|
+
.start-btn:active:not(:disabled) { transform: translateY(0); }
|
|
144
|
+
.start-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
145
|
+
.btn-icon { width: 20px; height: 20px; flex-shrink: 0; }
|
|
146
|
+
|
|
147
|
+
${this._sharedStyles()}
|
|
148
|
+
</style>
|
|
149
|
+
|
|
150
|
+
<div class="container" role="region" aria-label="Second-factor authentication">
|
|
151
|
+
<div id="body">
|
|
152
|
+
${this._bodyForStatus('idle')}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
`;
|
|
156
|
+
// Re-bind events after full render (attributeChangedCallback re-renders)
|
|
157
|
+
this._bindBodyEvents();
|
|
158
|
+
}
|
|
159
|
+
// ---- Body HTML per status ------------------------------------------------
|
|
160
|
+
_bodyForStatus(status, data) {
|
|
161
|
+
const stepLabel = `
|
|
162
|
+
<div class="step-label">
|
|
163
|
+
<span class="step-dot"></span>
|
|
164
|
+
Step 2 of 2 — Verify identity
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
switch (status) {
|
|
168
|
+
case 'idle':
|
|
169
|
+
return `
|
|
170
|
+
${stepLabel}
|
|
171
|
+
<div class="state-header">
|
|
172
|
+
<div class="brand-icon">${SHIELD_SVG}</div>
|
|
173
|
+
<h3>Verify your identity</h3>
|
|
174
|
+
<p class="subtitle">Scan the QR code with your phone to confirm it's you</p>
|
|
175
|
+
</div>
|
|
176
|
+
<button class="start-btn" id="start-btn" aria-label="Start 2FA verification">
|
|
177
|
+
<span class="btn-icon">${SHIELD_SVG}</span>
|
|
178
|
+
Verify your identity
|
|
179
|
+
</button>
|
|
180
|
+
`;
|
|
181
|
+
case 'loading':
|
|
182
|
+
return `
|
|
183
|
+
${stepLabel}
|
|
184
|
+
<div class="state-loading">
|
|
185
|
+
<div class="spinner-wrap">${SPINNER_SVG}</div>
|
|
186
|
+
<p class="status-text">Generating QR code...</p>
|
|
187
|
+
</div>
|
|
188
|
+
`;
|
|
189
|
+
case 'pending':
|
|
190
|
+
case 'SCANNED': {
|
|
191
|
+
const qrImageUrl = data?.qrUrl
|
|
192
|
+
? `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(data.qrUrl)}`
|
|
193
|
+
: '';
|
|
194
|
+
const isScanned = status === 'SCANNED';
|
|
195
|
+
const scanClass = isScanned ? 'scanned' : '';
|
|
196
|
+
return `
|
|
197
|
+
${stepLabel}
|
|
198
|
+
<h3>Scan to verify</h3>
|
|
199
|
+
<p class="subtitle">Use your phone camera to complete verification</p>
|
|
200
|
+
<div class="qr-frame ${scanClass}">
|
|
201
|
+
<img src="${qrImageUrl}" alt="QR Code for 2FA verification" width="160" height="160" loading="lazy"/>
|
|
202
|
+
<div class="qr-badge">${SHIELD_SVG}</div>
|
|
203
|
+
${isScanned ? '<div class="scanned-overlay"><span class="scanned-check">' + CHECK_SVG + '</span></div>' : ''}
|
|
204
|
+
</div>
|
|
205
|
+
<div class="status-text ${isScanned ? 'status-scanned' : ''}">
|
|
206
|
+
${isScanned
|
|
207
|
+
? '✓ Scanned — waiting for approval...'
|
|
208
|
+
: 'Waiting for scan...'}
|
|
209
|
+
</div>
|
|
210
|
+
<div class="timer" id="timer">${data?.timerHtml ?? ''}</div>
|
|
211
|
+
<div class="footer">Secured by <a href="https://qrauth.io" target="_blank" rel="noopener">QRAuth</a></div>
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
case 'APPROVED':
|
|
215
|
+
return `
|
|
216
|
+
<div class="state-approved">
|
|
217
|
+
<div class="success-icon">${CHECK_SVG}</div>
|
|
218
|
+
<h3>Identity verified!</h3>
|
|
219
|
+
<p class="subtitle">Second factor confirmed</p>
|
|
220
|
+
</div>
|
|
221
|
+
`;
|
|
222
|
+
case 'DENIED':
|
|
223
|
+
return `
|
|
224
|
+
<div class="state-denied">
|
|
225
|
+
<div class="error-icon">✗</div>
|
|
226
|
+
<h3>Verification denied</h3>
|
|
227
|
+
<p class="subtitle">The request was declined on your device.</p>
|
|
228
|
+
<button class="retry-btn" id="retry-btn">Try again</button>
|
|
229
|
+
</div>
|
|
230
|
+
`;
|
|
231
|
+
case 'EXPIRED':
|
|
232
|
+
return `
|
|
233
|
+
<div class="state-expired">
|
|
234
|
+
<div class="expired-icon">⌛</div>
|
|
235
|
+
<h3>Session expired</h3>
|
|
236
|
+
<p class="subtitle">The QR code has timed out.</p>
|
|
237
|
+
<button class="retry-btn" id="retry-btn">Try again</button>
|
|
238
|
+
</div>
|
|
239
|
+
`;
|
|
240
|
+
case 'error':
|
|
241
|
+
return `
|
|
242
|
+
<div class="state-error">
|
|
243
|
+
<div class="error-icon">⚠</div>
|
|
244
|
+
<h3>Something went wrong</h3>
|
|
245
|
+
<p class="subtitle">${data?.errorMessage ?? 'Failed to start verification.'}</p>
|
|
246
|
+
<button class="retry-btn" id="retry-btn">Try again</button>
|
|
247
|
+
</div>
|
|
248
|
+
`;
|
|
249
|
+
default:
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ---- Shared CSS ----------------------------------------------------------
|
|
254
|
+
_sharedStyles() {
|
|
255
|
+
return `
|
|
256
|
+
/* Typography */
|
|
257
|
+
h3 {
|
|
258
|
+
font-size: 16px;
|
|
259
|
+
font-weight: 700;
|
|
260
|
+
color: var(--_text);
|
|
261
|
+
margin: 0 0 5px;
|
|
262
|
+
}
|
|
263
|
+
.subtitle {
|
|
264
|
+
font-size: 12px;
|
|
265
|
+
color: var(--_text-muted);
|
|
266
|
+
margin: 0 0 16px;
|
|
267
|
+
line-height: 1.5;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* Brand icon in idle state */
|
|
271
|
+
.state-header { padding: 4px 0 2px; }
|
|
272
|
+
.brand-icon {
|
|
273
|
+
width: 48px;
|
|
274
|
+
height: 48px;
|
|
275
|
+
background: var(--_btn-bg);
|
|
276
|
+
border-radius: 12px;
|
|
277
|
+
display: inline-flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
justify-content: center;
|
|
280
|
+
margin-bottom: 12px;
|
|
281
|
+
padding: 8px;
|
|
282
|
+
}
|
|
283
|
+
.brand-icon svg { width: 32px; height: 32px; }
|
|
284
|
+
|
|
285
|
+
/* Loading state */
|
|
286
|
+
.state-loading {
|
|
287
|
+
padding: 24px 0;
|
|
288
|
+
display: flex;
|
|
289
|
+
flex-direction: column;
|
|
290
|
+
align-items: center;
|
|
291
|
+
gap: 14px;
|
|
292
|
+
}
|
|
293
|
+
.spinner-wrap { color: var(--_primary); }
|
|
294
|
+
.spinner {
|
|
295
|
+
width: 36px;
|
|
296
|
+
height: 36px;
|
|
297
|
+
animation: spin 0.8s linear infinite;
|
|
298
|
+
}
|
|
299
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
300
|
+
|
|
301
|
+
/* QR frame — compact 160x160 */
|
|
302
|
+
.qr-frame {
|
|
303
|
+
display: inline-block;
|
|
304
|
+
padding: 10px;
|
|
305
|
+
background: #fff;
|
|
306
|
+
border: 2px solid var(--_border);
|
|
307
|
+
border-radius: var(--_radius);
|
|
308
|
+
margin-bottom: 12px;
|
|
309
|
+
position: relative;
|
|
310
|
+
transition: border-color 0.3s;
|
|
311
|
+
}
|
|
312
|
+
.qr-frame.scanned { border-color: var(--_success); }
|
|
313
|
+
.qr-frame img { display: block; width: 160px; height: 160px; border-radius: 4px; }
|
|
314
|
+
|
|
315
|
+
/* Smaller badge for compact QR */
|
|
316
|
+
.qr-badge {
|
|
317
|
+
position: absolute;
|
|
318
|
+
top: 50%;
|
|
319
|
+
left: 50%;
|
|
320
|
+
transform: translate(-50%, -50%);
|
|
321
|
+
width: 40px;
|
|
322
|
+
height: 40px;
|
|
323
|
+
background: #1b2a4a;
|
|
324
|
+
border-radius: 8px;
|
|
325
|
+
padding: 5px;
|
|
326
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
justify-content: center;
|
|
330
|
+
}
|
|
331
|
+
.qr-badge svg { width: 100%; height: 100%; }
|
|
332
|
+
|
|
333
|
+
/* Scanned overlay on QR */
|
|
334
|
+
.scanned-overlay {
|
|
335
|
+
position: absolute;
|
|
336
|
+
inset: 0;
|
|
337
|
+
background: rgba(0,167,111,0.08);
|
|
338
|
+
border-radius: calc(var(--_radius) - 2px);
|
|
339
|
+
display: flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
justify-content: center;
|
|
342
|
+
}
|
|
343
|
+
.scanned-check {
|
|
344
|
+
width: 40px;
|
|
345
|
+
height: 40px;
|
|
346
|
+
background: var(--_success);
|
|
347
|
+
border-radius: 50%;
|
|
348
|
+
display: flex;
|
|
349
|
+
align-items: center;
|
|
350
|
+
justify-content: center;
|
|
351
|
+
color: #fff;
|
|
352
|
+
opacity: 0;
|
|
353
|
+
animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s forwards;
|
|
354
|
+
}
|
|
355
|
+
.scanned-check svg { width: 22px; height: 22px; }
|
|
356
|
+
@keyframes pop-in {
|
|
357
|
+
from { transform: scale(0.5); opacity: 0; }
|
|
358
|
+
to { transform: scale(1); opacity: 1; }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* Status text */
|
|
362
|
+
.status-text {
|
|
363
|
+
font-size: 12px;
|
|
364
|
+
color: var(--_text-muted);
|
|
365
|
+
min-height: 18px;
|
|
366
|
+
}
|
|
367
|
+
.status-scanned {
|
|
368
|
+
color: var(--_success);
|
|
369
|
+
font-weight: 600;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* Timer */
|
|
373
|
+
.timer {
|
|
374
|
+
font-size: 11px;
|
|
375
|
+
color: var(--_disabled);
|
|
376
|
+
margin-top: 6px;
|
|
377
|
+
font-variant-numeric: tabular-nums;
|
|
378
|
+
min-height: 16px;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* Success state */
|
|
382
|
+
.state-approved {
|
|
383
|
+
padding: 16px 0 8px;
|
|
384
|
+
animation: fade-scale-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
385
|
+
}
|
|
386
|
+
@keyframes fade-scale-in {
|
|
387
|
+
from { transform: scale(0.85); opacity: 0; }
|
|
388
|
+
to { transform: scale(1); opacity: 1; }
|
|
389
|
+
}
|
|
390
|
+
.success-icon {
|
|
391
|
+
width: 56px;
|
|
392
|
+
height: 56px;
|
|
393
|
+
background: var(--_success);
|
|
394
|
+
border-radius: 50%;
|
|
395
|
+
display: inline-flex;
|
|
396
|
+
align-items: center;
|
|
397
|
+
justify-content: center;
|
|
398
|
+
color: #fff;
|
|
399
|
+
margin-bottom: 14px;
|
|
400
|
+
}
|
|
401
|
+
.success-icon svg { width: 30px; height: 30px; }
|
|
402
|
+
|
|
403
|
+
/* Denied / expired / error states */
|
|
404
|
+
.state-denied, .state-expired, .state-error {
|
|
405
|
+
padding: 16px 0 6px;
|
|
406
|
+
}
|
|
407
|
+
.error-icon, .expired-icon {
|
|
408
|
+
font-size: 40px;
|
|
409
|
+
margin-bottom: 10px;
|
|
410
|
+
line-height: 1;
|
|
411
|
+
display: block;
|
|
412
|
+
}
|
|
413
|
+
.state-denied .error-icon { color: var(--_error); }
|
|
414
|
+
.state-expired .expired-icon { color: var(--_disabled); }
|
|
415
|
+
.state-error .error-icon { color: var(--_warning); }
|
|
416
|
+
|
|
417
|
+
/* Retry button */
|
|
418
|
+
.retry-btn {
|
|
419
|
+
margin-top: 12px;
|
|
420
|
+
padding: 8px 20px;
|
|
421
|
+
background: var(--_surface);
|
|
422
|
+
border: 1px solid var(--_border);
|
|
423
|
+
border-radius: 8px;
|
|
424
|
+
font-size: 12px;
|
|
425
|
+
font-weight: 500;
|
|
426
|
+
cursor: pointer;
|
|
427
|
+
color: var(--_text-muted);
|
|
428
|
+
font-family: inherit;
|
|
429
|
+
transition: background 0.15s, border-color 0.15s;
|
|
430
|
+
}
|
|
431
|
+
.retry-btn:hover { background: var(--_border); color: var(--_text); }
|
|
432
|
+
|
|
433
|
+
/* Footer */
|
|
434
|
+
.footer {
|
|
435
|
+
margin-top: 14px;
|
|
436
|
+
font-size: 10px;
|
|
437
|
+
color: #c4cdd5;
|
|
438
|
+
}
|
|
439
|
+
.footer a { color: var(--_disabled); text-decoration: none; }
|
|
440
|
+
.footer a:hover { text-decoration: underline; }
|
|
441
|
+
`;
|
|
442
|
+
}
|
|
443
|
+
// ---- Event binding -------------------------------------------------------
|
|
444
|
+
_bindBodyEvents() {
|
|
445
|
+
this.shadow.getElementById('start-btn')?.addEventListener('click', () => this._startAuth());
|
|
446
|
+
this.shadow.getElementById('retry-btn')?.addEventListener('click', () => this._retry());
|
|
447
|
+
}
|
|
448
|
+
// ---- Auth flow -----------------------------------------------------------
|
|
449
|
+
async _startAuth() {
|
|
450
|
+
this._status = 'loading';
|
|
451
|
+
this._updateBody(this._bodyForStatus('loading'));
|
|
452
|
+
try {
|
|
453
|
+
this._codeVerifier = await this.generateCodeVerifier();
|
|
454
|
+
const challenge = await this.computeCodeChallenge(this._codeVerifier);
|
|
455
|
+
const headers = {
|
|
456
|
+
'Content-Type': 'application/json',
|
|
457
|
+
'X-Client-Id': this.tenant,
|
|
458
|
+
};
|
|
459
|
+
const body = {
|
|
460
|
+
scopes: this.scopes.split(/\s+/).filter(Boolean),
|
|
461
|
+
codeChallenge: challenge,
|
|
462
|
+
codeChallengeMethod: 'S256',
|
|
463
|
+
};
|
|
464
|
+
const res = await fetch(`${this.baseUrl}/api/v1/auth-sessions`, {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers,
|
|
467
|
+
body: JSON.stringify(body),
|
|
468
|
+
});
|
|
469
|
+
if (!res.ok) {
|
|
470
|
+
const err = await res.json().catch(() => ({ message: 'Failed to create session' }));
|
|
471
|
+
throw new Error(err.message ?? 'Failed to create session');
|
|
472
|
+
}
|
|
473
|
+
const session = await res.json();
|
|
474
|
+
this._sessionId = session.sessionId;
|
|
475
|
+
this._expiresAt = new Date(session.expiresAt).getTime();
|
|
476
|
+
this._status = 'pending';
|
|
477
|
+
this._updateBody(this._bodyForStatus('pending', { qrUrl: session.qrUrl }));
|
|
478
|
+
this._bindBodyEvents();
|
|
479
|
+
this._startCountdown();
|
|
480
|
+
this._beginPolling(session.sessionId);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
const message = err instanceof Error ? err.message : 'Unexpected error';
|
|
484
|
+
this._status = 'error';
|
|
485
|
+
this._updateBody(this._bodyForStatus('error', { errorMessage: message }));
|
|
486
|
+
this._bindBodyEvents();
|
|
487
|
+
this.emit('qrauth:error', { message });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
_retry() {
|
|
491
|
+
this._clearTimers();
|
|
492
|
+
this._sessionId = null;
|
|
493
|
+
this._codeVerifier = null;
|
|
494
|
+
this._status = 'idle';
|
|
495
|
+
this._expiresAt = null;
|
|
496
|
+
this._updateBody(this._bodyForStatus('idle'));
|
|
497
|
+
this._bindBodyEvents();
|
|
498
|
+
}
|
|
499
|
+
// ---- Polling -------------------------------------------------------------
|
|
500
|
+
_beginPolling(sessionId) {
|
|
501
|
+
let lastStatus = 'PENDING';
|
|
502
|
+
let interval = 2000;
|
|
503
|
+
const maxInterval = 5000;
|
|
504
|
+
let errorCount = 0;
|
|
505
|
+
const maxErrors = 20;
|
|
506
|
+
const poll = async () => {
|
|
507
|
+
if (!this._sessionId)
|
|
508
|
+
return; // component was reset
|
|
509
|
+
let url = `${this.baseUrl}/api/v1/auth-sessions/${sessionId}`;
|
|
510
|
+
if (this._codeVerifier) {
|
|
511
|
+
url += `?code_verifier=${encodeURIComponent(this._codeVerifier)}`;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const res = await fetch(url, {
|
|
515
|
+
headers: { 'X-Client-Id': this.tenant },
|
|
516
|
+
});
|
|
517
|
+
const data = await res.json();
|
|
518
|
+
errorCount = 0;
|
|
519
|
+
interval = 2000;
|
|
520
|
+
if (data.status !== lastStatus) {
|
|
521
|
+
lastStatus = data.status;
|
|
522
|
+
this._handleStatusChange(data.status, data);
|
|
523
|
+
if (['APPROVED', 'DENIED', 'EXPIRED'].includes(data.status)) {
|
|
524
|
+
return; // stop polling
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
this._pollTimeout = setTimeout(poll, interval);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
errorCount++;
|
|
531
|
+
if (errorCount >= maxErrors) {
|
|
532
|
+
this._handleStatusChange('EXPIRED', undefined);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
interval = Math.min(interval * 1.5, maxInterval);
|
|
536
|
+
this._pollTimeout = setTimeout(poll, interval);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
this._pollTimeout = setTimeout(poll, interval);
|
|
540
|
+
}
|
|
541
|
+
// ---- Status handling -----------------------------------------------------
|
|
542
|
+
_handleStatusChange(status, data) {
|
|
543
|
+
this._status = status;
|
|
544
|
+
switch (status) {
|
|
545
|
+
case 'SCANNED': {
|
|
546
|
+
// Preserve current QR URL from the existing img element
|
|
547
|
+
const currentQrUrl = this.shadow.querySelector('.qr-frame img')?.src;
|
|
548
|
+
const rawQrUrl = currentQrUrl
|
|
549
|
+
? decodeURIComponent(currentQrUrl.replace(/.*[?&]data=/, '').split('&')[0])
|
|
550
|
+
: '';
|
|
551
|
+
this._updateBody(this._bodyForStatus('SCANNED', {
|
|
552
|
+
qrUrl: rawQrUrl,
|
|
553
|
+
timerHtml: this._formatTimer(),
|
|
554
|
+
}));
|
|
555
|
+
this._bindBodyEvents();
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
case 'APPROVED': {
|
|
559
|
+
this._clearTimers();
|
|
560
|
+
this._updateBody(this._bodyForStatus('APPROVED'));
|
|
561
|
+
this.emit('qrauth:verified', {
|
|
562
|
+
sessionId: data?.sessionId,
|
|
563
|
+
user: data?.user,
|
|
564
|
+
signature: data?.signature,
|
|
565
|
+
});
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case 'DENIED':
|
|
569
|
+
this._clearTimers();
|
|
570
|
+
this._updateBody(this._bodyForStatus('DENIED'));
|
|
571
|
+
this._bindBodyEvents();
|
|
572
|
+
this.emit('qrauth:denied');
|
|
573
|
+
break;
|
|
574
|
+
case 'EXPIRED':
|
|
575
|
+
this._clearTimers();
|
|
576
|
+
this._status = 'EXPIRED';
|
|
577
|
+
this._updateBody(this._bodyForStatus('EXPIRED'));
|
|
578
|
+
this._bindBodyEvents();
|
|
579
|
+
this.emit('qrauth:expired');
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// ---- Countdown timer -----------------------------------------------------
|
|
584
|
+
_startCountdown() {
|
|
585
|
+
this._timerInterval = setInterval(() => {
|
|
586
|
+
const timerEl = this.shadow.getElementById('timer');
|
|
587
|
+
if (!timerEl || !this._expiresAt)
|
|
588
|
+
return;
|
|
589
|
+
const remaining = Math.max(0, Math.floor((this._expiresAt - Date.now()) / 1000));
|
|
590
|
+
timerEl.textContent = remaining > 0 ? this._formatTimer(remaining) : '';
|
|
591
|
+
if (remaining <= 0) {
|
|
592
|
+
clearInterval(this._timerInterval);
|
|
593
|
+
this._timerInterval = null;
|
|
594
|
+
if (this._status === 'pending' || this._status === 'SCANNED') {
|
|
595
|
+
this._handleStatusChange('EXPIRED', undefined);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}, 1000);
|
|
599
|
+
}
|
|
600
|
+
_formatTimer(seconds) {
|
|
601
|
+
const s = seconds ?? (this._expiresAt
|
|
602
|
+
? Math.max(0, Math.floor((this._expiresAt - Date.now()) / 1000))
|
|
603
|
+
: 0);
|
|
604
|
+
const mins = Math.floor(s / 60);
|
|
605
|
+
const secs = s % 60;
|
|
606
|
+
return `Expires in ${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
|
607
|
+
}
|
|
608
|
+
// ---- DOM helpers ---------------------------------------------------------
|
|
609
|
+
_updateBody(html) {
|
|
610
|
+
const bodyEl = this.shadow.getElementById('body');
|
|
611
|
+
if (bodyEl)
|
|
612
|
+
bodyEl.innerHTML = html;
|
|
613
|
+
}
|
|
614
|
+
_clearTimers() {
|
|
615
|
+
if (this._timerInterval) {
|
|
616
|
+
clearInterval(this._timerInterval);
|
|
617
|
+
this._timerInterval = null;
|
|
618
|
+
}
|
|
619
|
+
if (this._pollTimeout) {
|
|
620
|
+
clearTimeout(this._pollTimeout);
|
|
621
|
+
this._pollTimeout = null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
customElements.define('qrauth-2fa', QRAuthTwoFA);
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qrauth/web-components",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "QRAuth Web Components — drop-in authentication elements",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/qrauth-components.js",
|
|
7
|
+
"module": "dist/qrauth-components.esm.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/qrauth-components.esm.js",
|
|
13
|
+
"require": "./dist/qrauth-components.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc && node scripts/bundle.js",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"qrauth",
|
|
26
|
+
"web-components",
|
|
27
|
+
"custom-elements",
|
|
28
|
+
"authentication",
|
|
29
|
+
"qr-code",
|
|
30
|
+
"shadow-dom"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "Gernard Cerma",
|
|
34
|
+
"email": "aris@progressnet.gr",
|
|
35
|
+
"url": "https://github.com/aristech"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"homepage": "https://qrauth.io",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/qrauth-io/qrauth/issues"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/qrauth-io/qrauth"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"esbuild": "^0.24.0",
|
|
48
|
+
"typescript": "^5.7.0"
|
|
49
|
+
}
|
|
50
|
+
}
|