@douvery/auth 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/README.md +231 -0
- package/dist/index.d.ts +352 -0
- package/dist/index.js +722 -0
- package/dist/index.js.map +1 -0
- package/package.json +1 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var AuthError = class extends Error {
|
|
3
|
+
constructor(code, message, cause) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.cause = cause;
|
|
7
|
+
this.name = "AuthError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/pkce.ts
|
|
12
|
+
function generateCodeVerifier(length = 64) {
|
|
13
|
+
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
14
|
+
const randomValues = crypto.getRandomValues(new Uint8Array(length));
|
|
15
|
+
return Array.from(randomValues).map((v) => charset[v % charset.length]).join("");
|
|
16
|
+
}
|
|
17
|
+
function generateState() {
|
|
18
|
+
return generateCodeVerifier(32);
|
|
19
|
+
}
|
|
20
|
+
function generateNonce() {
|
|
21
|
+
return generateCodeVerifier(32);
|
|
22
|
+
}
|
|
23
|
+
async function generateCodeChallenge(verifier) {
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
const data = encoder.encode(verifier);
|
|
26
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
27
|
+
return base64UrlEncode(hashBuffer);
|
|
28
|
+
}
|
|
29
|
+
function base64UrlEncode(buffer) {
|
|
30
|
+
const bytes = new Uint8Array(buffer);
|
|
31
|
+
let binary = "";
|
|
32
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
33
|
+
binary += String.fromCharCode(bytes[i]);
|
|
34
|
+
}
|
|
35
|
+
const base64 = btoa(binary);
|
|
36
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
function base64UrlDecode(input) {
|
|
39
|
+
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
40
|
+
const padding = base64.length % 4;
|
|
41
|
+
if (padding) {
|
|
42
|
+
base64 += "=".repeat(4 - padding);
|
|
43
|
+
}
|
|
44
|
+
const binary = atob(base64);
|
|
45
|
+
const bytes = new Uint8Array(binary.length);
|
|
46
|
+
for (let i = 0; i < binary.length; i++) {
|
|
47
|
+
bytes[i] = binary.charCodeAt(i);
|
|
48
|
+
}
|
|
49
|
+
return bytes.buffer;
|
|
50
|
+
}
|
|
51
|
+
async function generatePKCEPair() {
|
|
52
|
+
const codeVerifier = generateCodeVerifier();
|
|
53
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
54
|
+
return {
|
|
55
|
+
codeVerifier,
|
|
56
|
+
codeChallenge,
|
|
57
|
+
codeChallengeMethod: "S256"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function verifyCodeChallenge(verifier, challenge, method = "S256") {
|
|
61
|
+
if (method === "plain") {
|
|
62
|
+
return verifier === challenge;
|
|
63
|
+
}
|
|
64
|
+
const computedChallenge = await generateCodeChallenge(verifier);
|
|
65
|
+
return computedChallenge === challenge;
|
|
66
|
+
}
|
|
67
|
+
function decodeJWT(token) {
|
|
68
|
+
const parts = token.split(".");
|
|
69
|
+
if (parts.length !== 3) {
|
|
70
|
+
throw new Error("Invalid JWT format");
|
|
71
|
+
}
|
|
72
|
+
const payload = parts[1];
|
|
73
|
+
const decoded = base64UrlDecode(payload);
|
|
74
|
+
const text = new TextDecoder().decode(decoded);
|
|
75
|
+
return JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
function isTokenExpired(token, clockSkew = 60) {
|
|
78
|
+
try {
|
|
79
|
+
const payload = decodeJWT(token);
|
|
80
|
+
if (!payload.exp) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
84
|
+
return payload.exp < now - clockSkew;
|
|
85
|
+
} catch {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function getTokenExpiration(token) {
|
|
90
|
+
try {
|
|
91
|
+
const payload = decodeJWT(token);
|
|
92
|
+
return payload.exp ? payload.exp * 1e3 : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/storage.ts
|
|
99
|
+
var DEFAULT_PREFIX = "douvery_auth";
|
|
100
|
+
var STORAGE_KEYS = {
|
|
101
|
+
accessToken: `${DEFAULT_PREFIX}_access_token`,
|
|
102
|
+
refreshToken: `${DEFAULT_PREFIX}_refresh_token`,
|
|
103
|
+
idToken: `${DEFAULT_PREFIX}_id_token`,
|
|
104
|
+
expiresAt: `${DEFAULT_PREFIX}_expires_at`,
|
|
105
|
+
state: `${DEFAULT_PREFIX}_state`,
|
|
106
|
+
nonce: `${DEFAULT_PREFIX}_nonce`,
|
|
107
|
+
codeVerifier: `${DEFAULT_PREFIX}_code_verifier`,
|
|
108
|
+
returnTo: `${DEFAULT_PREFIX}_return_to`
|
|
109
|
+
};
|
|
110
|
+
var MemoryStorage = class {
|
|
111
|
+
store = /* @__PURE__ */ new Map();
|
|
112
|
+
get(key) {
|
|
113
|
+
return this.store.get(key) ?? null;
|
|
114
|
+
}
|
|
115
|
+
set(key, value) {
|
|
116
|
+
this.store.set(key, value);
|
|
117
|
+
}
|
|
118
|
+
remove(key) {
|
|
119
|
+
this.store.delete(key);
|
|
120
|
+
}
|
|
121
|
+
clear() {
|
|
122
|
+
this.store.clear();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
var LocalStorage = class {
|
|
126
|
+
get(key) {
|
|
127
|
+
if (typeof window === "undefined") return null;
|
|
128
|
+
return localStorage.getItem(key);
|
|
129
|
+
}
|
|
130
|
+
set(key, value) {
|
|
131
|
+
if (typeof window === "undefined") return;
|
|
132
|
+
localStorage.setItem(key, value);
|
|
133
|
+
}
|
|
134
|
+
remove(key) {
|
|
135
|
+
if (typeof window === "undefined") return;
|
|
136
|
+
localStorage.removeItem(key);
|
|
137
|
+
}
|
|
138
|
+
clear() {
|
|
139
|
+
if (typeof window === "undefined") return;
|
|
140
|
+
Object.values(STORAGE_KEYS).forEach((key) => {
|
|
141
|
+
localStorage.removeItem(key);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var SessionStorage = class {
|
|
146
|
+
get(key) {
|
|
147
|
+
if (typeof window === "undefined") return null;
|
|
148
|
+
return sessionStorage.getItem(key);
|
|
149
|
+
}
|
|
150
|
+
set(key, value) {
|
|
151
|
+
if (typeof window === "undefined") return;
|
|
152
|
+
sessionStorage.setItem(key, value);
|
|
153
|
+
}
|
|
154
|
+
remove(key) {
|
|
155
|
+
if (typeof window === "undefined") return;
|
|
156
|
+
sessionStorage.removeItem(key);
|
|
157
|
+
}
|
|
158
|
+
clear() {
|
|
159
|
+
if (typeof window === "undefined") return;
|
|
160
|
+
Object.values(STORAGE_KEYS).forEach((key) => {
|
|
161
|
+
sessionStorage.removeItem(key);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var CookieStorage = class {
|
|
166
|
+
constructor(options = {}) {
|
|
167
|
+
this.options = options;
|
|
168
|
+
this.options = { path: "/", secure: true, sameSite: "Lax", ...options };
|
|
169
|
+
}
|
|
170
|
+
get(key) {
|
|
171
|
+
if (typeof document === "undefined") return null;
|
|
172
|
+
const cookies = document.cookie.split(";");
|
|
173
|
+
for (const cookie of cookies) {
|
|
174
|
+
const [name, value] = cookie.trim().split("=");
|
|
175
|
+
if (name === key) {
|
|
176
|
+
return decodeURIComponent(value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
set(key, value) {
|
|
182
|
+
if (typeof document === "undefined") return;
|
|
183
|
+
const parts = [`${key}=${encodeURIComponent(value)}`, `path=${this.options.path}`];
|
|
184
|
+
if (this.options.domain) parts.push(`domain=${this.options.domain}`);
|
|
185
|
+
if (this.options.secure) parts.push("secure");
|
|
186
|
+
if (this.options.sameSite) parts.push(`samesite=${this.options.sameSite}`);
|
|
187
|
+
if (this.options.maxAge) parts.push(`max-age=${this.options.maxAge}`);
|
|
188
|
+
document.cookie = parts.join("; ");
|
|
189
|
+
}
|
|
190
|
+
remove(key) {
|
|
191
|
+
if (typeof document === "undefined") return;
|
|
192
|
+
document.cookie = `${key}=; path=${this.options.path}; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
193
|
+
}
|
|
194
|
+
clear() {
|
|
195
|
+
Object.values(STORAGE_KEYS).forEach((key) => this.remove(key));
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
function createStorage(type) {
|
|
199
|
+
switch (type) {
|
|
200
|
+
case "localStorage":
|
|
201
|
+
return new LocalStorage();
|
|
202
|
+
case "sessionStorage":
|
|
203
|
+
return new SessionStorage();
|
|
204
|
+
case "cookie":
|
|
205
|
+
return new CookieStorage();
|
|
206
|
+
case "memory":
|
|
207
|
+
default:
|
|
208
|
+
return new MemoryStorage();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
var TokenManager = class {
|
|
212
|
+
constructor(storage) {
|
|
213
|
+
this.storage = storage;
|
|
214
|
+
}
|
|
215
|
+
async getTokens() {
|
|
216
|
+
const accessToken = await this.storage.get(STORAGE_KEYS.accessToken);
|
|
217
|
+
if (!accessToken) return null;
|
|
218
|
+
const refreshToken = await this.storage.get(STORAGE_KEYS.refreshToken);
|
|
219
|
+
const idToken = await this.storage.get(STORAGE_KEYS.idToken);
|
|
220
|
+
const expiresAt = await this.storage.get(STORAGE_KEYS.expiresAt);
|
|
221
|
+
return {
|
|
222
|
+
accessToken,
|
|
223
|
+
refreshToken: refreshToken ?? void 0,
|
|
224
|
+
idToken: idToken ?? void 0,
|
|
225
|
+
expiresAt: expiresAt ? parseInt(expiresAt, 10) : 0,
|
|
226
|
+
tokenType: "Bearer",
|
|
227
|
+
scope: []
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
async setTokens(tokens) {
|
|
231
|
+
await this.storage.set(STORAGE_KEYS.accessToken, tokens.accessToken);
|
|
232
|
+
await this.storage.set(STORAGE_KEYS.expiresAt, tokens.expiresAt.toString());
|
|
233
|
+
if (tokens.refreshToken) {
|
|
234
|
+
await this.storage.set(STORAGE_KEYS.refreshToken, tokens.refreshToken);
|
|
235
|
+
}
|
|
236
|
+
if (tokens.idToken) {
|
|
237
|
+
await this.storage.set(STORAGE_KEYS.idToken, tokens.idToken);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async clearTokens() {
|
|
241
|
+
await this.storage.remove(STORAGE_KEYS.accessToken);
|
|
242
|
+
await this.storage.remove(STORAGE_KEYS.refreshToken);
|
|
243
|
+
await this.storage.remove(STORAGE_KEYS.idToken);
|
|
244
|
+
await this.storage.remove(STORAGE_KEYS.expiresAt);
|
|
245
|
+
}
|
|
246
|
+
async saveState(state) {
|
|
247
|
+
await this.storage.set(STORAGE_KEYS.state, state);
|
|
248
|
+
}
|
|
249
|
+
async getState() {
|
|
250
|
+
return this.storage.get(STORAGE_KEYS.state);
|
|
251
|
+
}
|
|
252
|
+
async clearState() {
|
|
253
|
+
await this.storage.remove(STORAGE_KEYS.state);
|
|
254
|
+
}
|
|
255
|
+
async saveNonce(nonce) {
|
|
256
|
+
await this.storage.set(STORAGE_KEYS.nonce, nonce);
|
|
257
|
+
}
|
|
258
|
+
async getNonce() {
|
|
259
|
+
return this.storage.get(STORAGE_KEYS.nonce);
|
|
260
|
+
}
|
|
261
|
+
async clearNonce() {
|
|
262
|
+
await this.storage.remove(STORAGE_KEYS.nonce);
|
|
263
|
+
}
|
|
264
|
+
async saveCodeVerifier(verifier) {
|
|
265
|
+
await this.storage.set(STORAGE_KEYS.codeVerifier, verifier);
|
|
266
|
+
}
|
|
267
|
+
async getCodeVerifier() {
|
|
268
|
+
return this.storage.get(STORAGE_KEYS.codeVerifier);
|
|
269
|
+
}
|
|
270
|
+
async clearCodeVerifier() {
|
|
271
|
+
await this.storage.remove(STORAGE_KEYS.codeVerifier);
|
|
272
|
+
}
|
|
273
|
+
async saveReturnTo(url) {
|
|
274
|
+
await this.storage.set(STORAGE_KEYS.returnTo, url);
|
|
275
|
+
}
|
|
276
|
+
async getReturnTo() {
|
|
277
|
+
return this.storage.get(STORAGE_KEYS.returnTo);
|
|
278
|
+
}
|
|
279
|
+
async clearReturnTo() {
|
|
280
|
+
await this.storage.remove(STORAGE_KEYS.returnTo);
|
|
281
|
+
}
|
|
282
|
+
async clearAll() {
|
|
283
|
+
await this.storage.clear();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// src/client.ts
|
|
288
|
+
var DEFAULT_ISSUER = "https://auth.douvery.com";
|
|
289
|
+
var DEFAULT_SCOPES = ["openid", "profile", "email"];
|
|
290
|
+
var DouveryAuthClient = class {
|
|
291
|
+
config;
|
|
292
|
+
tokenManager;
|
|
293
|
+
discovery = null;
|
|
294
|
+
eventHandlers = /* @__PURE__ */ new Set();
|
|
295
|
+
refreshTimer = null;
|
|
296
|
+
state = {
|
|
297
|
+
status: "loading",
|
|
298
|
+
user: null,
|
|
299
|
+
tokens: null,
|
|
300
|
+
error: null
|
|
301
|
+
};
|
|
302
|
+
constructor(config) {
|
|
303
|
+
this.config = {
|
|
304
|
+
issuer: DEFAULT_ISSUER,
|
|
305
|
+
scopes: DEFAULT_SCOPES,
|
|
306
|
+
storage: "localStorage",
|
|
307
|
+
autoRefresh: true,
|
|
308
|
+
refreshThreshold: 60,
|
|
309
|
+
debug: false,
|
|
310
|
+
...config
|
|
311
|
+
};
|
|
312
|
+
const storage = config.customStorage ?? createStorage(this.config.storage ?? "localStorage");
|
|
313
|
+
this.tokenManager = new TokenManager(storage);
|
|
314
|
+
}
|
|
315
|
+
/** Initialize the auth client */
|
|
316
|
+
async initialize() {
|
|
317
|
+
this.log("Initializing auth client...");
|
|
318
|
+
try {
|
|
319
|
+
if (this.isCallback()) {
|
|
320
|
+
this.log("Handling OAuth callback...");
|
|
321
|
+
const result = await this.handleCallback();
|
|
322
|
+
if (result.success && result.user && result.tokens) {
|
|
323
|
+
this.updateState({
|
|
324
|
+
status: "authenticated",
|
|
325
|
+
user: result.user,
|
|
326
|
+
tokens: result.tokens,
|
|
327
|
+
error: null
|
|
328
|
+
});
|
|
329
|
+
this.setupAutoRefresh();
|
|
330
|
+
} else {
|
|
331
|
+
this.updateState({
|
|
332
|
+
status: "unauthenticated",
|
|
333
|
+
user: null,
|
|
334
|
+
tokens: null,
|
|
335
|
+
error: result.error ?? null
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
const tokens = await this.tokenManager.getTokens();
|
|
340
|
+
if (tokens && tokens.accessToken) {
|
|
341
|
+
if (!isTokenExpired(tokens.accessToken)) {
|
|
342
|
+
this.log("Found valid existing session");
|
|
343
|
+
const user = await this.fetchUser(tokens.accessToken);
|
|
344
|
+
this.updateState({
|
|
345
|
+
status: "authenticated",
|
|
346
|
+
user,
|
|
347
|
+
tokens,
|
|
348
|
+
error: null
|
|
349
|
+
});
|
|
350
|
+
this.setupAutoRefresh();
|
|
351
|
+
} else if (tokens.refreshToken) {
|
|
352
|
+
this.log("Access token expired, attempting refresh...");
|
|
353
|
+
await this.refreshTokens();
|
|
354
|
+
} else {
|
|
355
|
+
this.log("Session expired, no refresh token");
|
|
356
|
+
await this.tokenManager.clearTokens();
|
|
357
|
+
this.updateState({
|
|
358
|
+
status: "unauthenticated",
|
|
359
|
+
user: null,
|
|
360
|
+
tokens: null,
|
|
361
|
+
error: null
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
this.log("No existing session found");
|
|
366
|
+
this.updateState({
|
|
367
|
+
status: "unauthenticated",
|
|
368
|
+
user: null,
|
|
369
|
+
tokens: null,
|
|
370
|
+
error: null
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
this.emit({ type: "INITIALIZED" });
|
|
375
|
+
} catch (error) {
|
|
376
|
+
this.log("Initialization error:", error);
|
|
377
|
+
this.updateState({
|
|
378
|
+
status: "unauthenticated",
|
|
379
|
+
user: null,
|
|
380
|
+
tokens: null,
|
|
381
|
+
error: error instanceof AuthError ? error : new AuthError("unknown_error", "Initialization failed", error)
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return this.state;
|
|
385
|
+
}
|
|
386
|
+
/** Start the login flow */
|
|
387
|
+
async login(options = {}) {
|
|
388
|
+
this.log("Starting login flow...");
|
|
389
|
+
this.emit({ type: "LOGIN_STARTED" });
|
|
390
|
+
try {
|
|
391
|
+
const discovery = await this.getDiscovery();
|
|
392
|
+
const pkce = await generatePKCEPair();
|
|
393
|
+
const state = generateState();
|
|
394
|
+
const nonce = generateNonce();
|
|
395
|
+
await this.tokenManager.saveState(state);
|
|
396
|
+
await this.tokenManager.saveNonce(nonce);
|
|
397
|
+
await this.tokenManager.saveCodeVerifier(pkce.codeVerifier);
|
|
398
|
+
if (options.returnTo) {
|
|
399
|
+
await this.tokenManager.saveReturnTo(options.returnTo);
|
|
400
|
+
}
|
|
401
|
+
const params = new URLSearchParams({
|
|
402
|
+
response_type: "code",
|
|
403
|
+
client_id: this.config.clientId,
|
|
404
|
+
redirect_uri: this.config.redirectUri,
|
|
405
|
+
scope: this.config.scopes.join(" "),
|
|
406
|
+
state,
|
|
407
|
+
nonce,
|
|
408
|
+
code_challenge: pkce.codeChallenge,
|
|
409
|
+
code_challenge_method: pkce.codeChallengeMethod,
|
|
410
|
+
...options.authorizationParams
|
|
411
|
+
});
|
|
412
|
+
if (options.prompt) params.set("prompt", options.prompt);
|
|
413
|
+
if (options.loginHint) params.set("login_hint", options.loginHint);
|
|
414
|
+
if (options.uiLocales) params.set("ui_locales", options.uiLocales);
|
|
415
|
+
if (options.maxAge !== void 0) params.set("max_age", options.maxAge.toString());
|
|
416
|
+
if (options.acrValues) params.set("acr_values", options.acrValues);
|
|
417
|
+
const authUrl = `${discovery.authorization_endpoint}?${params}`;
|
|
418
|
+
this.log("Redirecting to:", authUrl);
|
|
419
|
+
window.location.href = authUrl;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const authError = error instanceof AuthError ? error : new AuthError("configuration_error", "Login failed", error);
|
|
422
|
+
this.emit({ type: "LOGIN_ERROR", error: authError });
|
|
423
|
+
throw authError;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** Logout the user */
|
|
427
|
+
async logout(options = {}) {
|
|
428
|
+
this.log("Starting logout...");
|
|
429
|
+
this.emit({ type: "LOGOUT_STARTED" });
|
|
430
|
+
try {
|
|
431
|
+
await this.tokenManager.clearAll();
|
|
432
|
+
this.clearAutoRefresh();
|
|
433
|
+
this.updateState({
|
|
434
|
+
status: "unauthenticated",
|
|
435
|
+
user: null,
|
|
436
|
+
tokens: null,
|
|
437
|
+
error: null
|
|
438
|
+
});
|
|
439
|
+
if (options.localOnly) {
|
|
440
|
+
this.emit({ type: "LOGOUT_SUCCESS" });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (options.federated !== false) {
|
|
444
|
+
const discovery = await this.getDiscovery();
|
|
445
|
+
if (discovery.end_session_endpoint) {
|
|
446
|
+
const params = new URLSearchParams();
|
|
447
|
+
if (this.state.tokens?.idToken) {
|
|
448
|
+
params.set("id_token_hint", this.state.tokens.idToken);
|
|
449
|
+
}
|
|
450
|
+
if (options.returnTo || this.config.postLogoutRedirectUri) {
|
|
451
|
+
params.set(
|
|
452
|
+
"post_logout_redirect_uri",
|
|
453
|
+
options.returnTo || this.config.postLogoutRedirectUri
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
params.set("client_id", this.config.clientId);
|
|
457
|
+
const logoutUrl = `${discovery.end_session_endpoint}?${params}`;
|
|
458
|
+
this.log("Redirecting to logout:", logoutUrl);
|
|
459
|
+
window.location.href = logoutUrl;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
this.emit({ type: "LOGOUT_SUCCESS" });
|
|
464
|
+
if (options.returnTo) {
|
|
465
|
+
window.location.href = options.returnTo;
|
|
466
|
+
}
|
|
467
|
+
} catch (error) {
|
|
468
|
+
const authError = error instanceof AuthError ? error : new AuthError("unknown_error", "Logout failed", error);
|
|
469
|
+
this.emit({ type: "LOGOUT_ERROR", error: authError });
|
|
470
|
+
throw authError;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** Check if current URL is an OAuth callback */
|
|
474
|
+
isCallback() {
|
|
475
|
+
if (typeof window === "undefined") return false;
|
|
476
|
+
const params = new URLSearchParams(window.location.search);
|
|
477
|
+
return params.has("code") || params.has("error");
|
|
478
|
+
}
|
|
479
|
+
/** Handle the OAuth callback */
|
|
480
|
+
async handleCallback() {
|
|
481
|
+
this.log("Processing callback...");
|
|
482
|
+
if (typeof window === "undefined") {
|
|
483
|
+
return {
|
|
484
|
+
success: false,
|
|
485
|
+
error: new AuthError("configuration_error", "Cannot handle callback on server")
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const params = new URLSearchParams(window.location.search);
|
|
489
|
+
const code = params.get("code");
|
|
490
|
+
const stateParam = params.get("state");
|
|
491
|
+
const errorParam = params.get("error");
|
|
492
|
+
const errorDescription = params.get("error_description");
|
|
493
|
+
if (errorParam) {
|
|
494
|
+
const error = new AuthError(
|
|
495
|
+
errorParam,
|
|
496
|
+
errorDescription ?? "Authorization failed"
|
|
497
|
+
);
|
|
498
|
+
return { success: false, error };
|
|
499
|
+
}
|
|
500
|
+
const savedState = await this.tokenManager.getState();
|
|
501
|
+
if (!stateParam || stateParam !== savedState) {
|
|
502
|
+
return {
|
|
503
|
+
success: false,
|
|
504
|
+
error: new AuthError("state_mismatch", "State parameter mismatch")
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (!code) {
|
|
508
|
+
return {
|
|
509
|
+
success: false,
|
|
510
|
+
error: new AuthError("invalid_request", "No authorization code received")
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const codeVerifier = await this.tokenManager.getCodeVerifier();
|
|
514
|
+
if (!codeVerifier) {
|
|
515
|
+
return {
|
|
516
|
+
success: false,
|
|
517
|
+
error: new AuthError("pkce_error", "No code verifier found")
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const tokens = await this.exchangeCode(code, codeVerifier);
|
|
522
|
+
await this.tokenManager.setTokens(tokens);
|
|
523
|
+
const user = await this.fetchUser(tokens.accessToken);
|
|
524
|
+
const returnTo = await this.tokenManager.getReturnTo();
|
|
525
|
+
await this.tokenManager.clearState();
|
|
526
|
+
await this.tokenManager.clearNonce();
|
|
527
|
+
await this.tokenManager.clearCodeVerifier();
|
|
528
|
+
await this.tokenManager.clearReturnTo();
|
|
529
|
+
window.history.replaceState({}, "", window.location.pathname);
|
|
530
|
+
this.emit({ type: "LOGIN_SUCCESS", user, tokens });
|
|
531
|
+
return { success: true, user, tokens, returnTo: returnTo ?? void 0 };
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const authError = error instanceof AuthError ? error : new AuthError("invalid_grant", "Token exchange failed", error);
|
|
534
|
+
this.emit({ type: "LOGIN_ERROR", error: authError });
|
|
535
|
+
return { success: false, error: authError };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async exchangeCode(code, codeVerifier) {
|
|
539
|
+
const discovery = await this.getDiscovery();
|
|
540
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
543
|
+
body: new URLSearchParams({
|
|
544
|
+
grant_type: "authorization_code",
|
|
545
|
+
code,
|
|
546
|
+
redirect_uri: this.config.redirectUri,
|
|
547
|
+
client_id: this.config.clientId,
|
|
548
|
+
code_verifier: codeVerifier
|
|
549
|
+
})
|
|
550
|
+
});
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
const error = await response.json().catch(() => ({}));
|
|
553
|
+
throw new AuthError(
|
|
554
|
+
error.error ?? "invalid_grant",
|
|
555
|
+
error.error_description ?? "Token exchange failed"
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const tokenSet = await response.json();
|
|
559
|
+
return this.tokenSetToInfo(tokenSet);
|
|
560
|
+
}
|
|
561
|
+
/** Refresh the access token */
|
|
562
|
+
async refreshTokens() {
|
|
563
|
+
this.log("Refreshing tokens...");
|
|
564
|
+
const tokens = await this.tokenManager.getTokens();
|
|
565
|
+
if (!tokens?.refreshToken) {
|
|
566
|
+
throw new AuthError("token_refresh_failed", "No refresh token available");
|
|
567
|
+
}
|
|
568
|
+
const discovery = await this.getDiscovery();
|
|
569
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
572
|
+
body: new URLSearchParams({
|
|
573
|
+
grant_type: "refresh_token",
|
|
574
|
+
refresh_token: tokens.refreshToken,
|
|
575
|
+
client_id: this.config.clientId
|
|
576
|
+
})
|
|
577
|
+
});
|
|
578
|
+
if (!response.ok) {
|
|
579
|
+
const error = await response.json().catch(() => ({}));
|
|
580
|
+
const authError = new AuthError(
|
|
581
|
+
error.error ?? "token_refresh_failed",
|
|
582
|
+
error.error_description ?? "Token refresh failed"
|
|
583
|
+
);
|
|
584
|
+
this.emit({ type: "TOKEN_REFRESH_ERROR", error: authError });
|
|
585
|
+
await this.tokenManager.clearTokens();
|
|
586
|
+
this.updateState({
|
|
587
|
+
status: "unauthenticated",
|
|
588
|
+
user: null,
|
|
589
|
+
tokens: null,
|
|
590
|
+
error: authError
|
|
591
|
+
});
|
|
592
|
+
this.emit({ type: "SESSION_EXPIRED" });
|
|
593
|
+
throw authError;
|
|
594
|
+
}
|
|
595
|
+
const tokenSet = await response.json();
|
|
596
|
+
const newTokens = this.tokenSetToInfo(tokenSet);
|
|
597
|
+
await this.tokenManager.setTokens(newTokens);
|
|
598
|
+
const user = newTokens.idToken ? this.extractUserFromIdToken(newTokens.idToken) : this.state.user;
|
|
599
|
+
this.updateState({ ...this.state, tokens: newTokens, user });
|
|
600
|
+
this.emit({ type: "TOKEN_REFRESHED", tokens: newTokens });
|
|
601
|
+
this.setupAutoRefresh();
|
|
602
|
+
return newTokens;
|
|
603
|
+
}
|
|
604
|
+
/** Get current access token (auto-refreshes if needed) */
|
|
605
|
+
async getAccessToken() {
|
|
606
|
+
const tokens = await this.tokenManager.getTokens();
|
|
607
|
+
if (!tokens) return null;
|
|
608
|
+
if (isTokenExpired(tokens.accessToken)) {
|
|
609
|
+
if (tokens.refreshToken) {
|
|
610
|
+
const newTokens = await this.refreshTokens();
|
|
611
|
+
return newTokens.accessToken;
|
|
612
|
+
}
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
return tokens.accessToken;
|
|
616
|
+
}
|
|
617
|
+
tokenSetToInfo(tokenSet) {
|
|
618
|
+
return {
|
|
619
|
+
accessToken: tokenSet.access_token,
|
|
620
|
+
refreshToken: tokenSet.refresh_token,
|
|
621
|
+
idToken: tokenSet.id_token,
|
|
622
|
+
expiresAt: Date.now() + tokenSet.expires_in * 1e3,
|
|
623
|
+
tokenType: tokenSet.token_type,
|
|
624
|
+
scope: tokenSet.scope?.split(" ") ?? []
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
async fetchUser(accessToken) {
|
|
628
|
+
const discovery = await this.getDiscovery();
|
|
629
|
+
const response = await fetch(discovery.userinfo_endpoint, {
|
|
630
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
631
|
+
});
|
|
632
|
+
if (!response.ok) {
|
|
633
|
+
throw new AuthError("invalid_token", "Failed to fetch user info");
|
|
634
|
+
}
|
|
635
|
+
const userInfo = await response.json();
|
|
636
|
+
return this.normalizeUser(userInfo);
|
|
637
|
+
}
|
|
638
|
+
extractUserFromIdToken(idToken) {
|
|
639
|
+
const claims = decodeJWT(idToken);
|
|
640
|
+
return this.normalizeUser(claims);
|
|
641
|
+
}
|
|
642
|
+
normalizeUser(claims) {
|
|
643
|
+
return {
|
|
644
|
+
id: claims.sub,
|
|
645
|
+
email: claims.email,
|
|
646
|
+
emailVerified: claims.email_verified,
|
|
647
|
+
name: claims.name,
|
|
648
|
+
firstName: claims.given_name,
|
|
649
|
+
lastName: claims.family_name,
|
|
650
|
+
picture: claims.picture,
|
|
651
|
+
phoneNumber: claims.phone_number,
|
|
652
|
+
phoneNumberVerified: claims.phone_number_verified,
|
|
653
|
+
locale: claims.locale,
|
|
654
|
+
...claims
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async getDiscovery() {
|
|
658
|
+
if (this.discovery) return this.discovery;
|
|
659
|
+
const discoveryUrl = `${this.config.issuer}/.well-known/openid-configuration`;
|
|
660
|
+
const response = await fetch(discoveryUrl);
|
|
661
|
+
if (!response.ok) {
|
|
662
|
+
throw new AuthError("configuration_error", "Failed to fetch discovery document");
|
|
663
|
+
}
|
|
664
|
+
this.discovery = await response.json();
|
|
665
|
+
return this.discovery;
|
|
666
|
+
}
|
|
667
|
+
setupAutoRefresh() {
|
|
668
|
+
if (!this.config.autoRefresh || !this.state.tokens) return;
|
|
669
|
+
this.clearAutoRefresh();
|
|
670
|
+
const expiresIn = this.state.tokens.expiresAt - Date.now();
|
|
671
|
+
const refreshIn = expiresIn - this.config.refreshThreshold * 1e3;
|
|
672
|
+
if (refreshIn > 0) {
|
|
673
|
+
this.log(`Scheduling token refresh in ${Math.round(refreshIn / 1e3)}s`);
|
|
674
|
+
this.refreshTimer = setTimeout(() => {
|
|
675
|
+
this.refreshTokens().catch((error) => this.log("Auto-refresh failed:", error));
|
|
676
|
+
}, refreshIn);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
clearAutoRefresh() {
|
|
680
|
+
if (this.refreshTimer) {
|
|
681
|
+
clearTimeout(this.refreshTimer);
|
|
682
|
+
this.refreshTimer = null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
getState() {
|
|
686
|
+
return { ...this.state };
|
|
687
|
+
}
|
|
688
|
+
isAuthenticated() {
|
|
689
|
+
return this.state.status === "authenticated";
|
|
690
|
+
}
|
|
691
|
+
getUser() {
|
|
692
|
+
return this.state.user;
|
|
693
|
+
}
|
|
694
|
+
subscribe(handler) {
|
|
695
|
+
this.eventHandlers.add(handler);
|
|
696
|
+
return () => this.eventHandlers.delete(handler);
|
|
697
|
+
}
|
|
698
|
+
updateState(newState) {
|
|
699
|
+
this.state = newState;
|
|
700
|
+
}
|
|
701
|
+
emit(event) {
|
|
702
|
+
this.eventHandlers.forEach((handler) => {
|
|
703
|
+
try {
|
|
704
|
+
handler(event);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
console.error("Event handler error:", error);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
log(...args) {
|
|
711
|
+
if (this.config.debug) {
|
|
712
|
+
console.log("[DouveryAuth]", ...args);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
function createDouveryAuth(config) {
|
|
717
|
+
return new DouveryAuthClient(config);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export { AuthError, CookieStorage, DouveryAuthClient, LocalStorage, MemoryStorage, STORAGE_KEYS, SessionStorage, TokenManager, base64UrlDecode, base64UrlEncode, createDouveryAuth, createStorage, decodeJWT, generateCodeChallenge, generateCodeVerifier, generateNonce, generatePKCEPair, generateState, getTokenExpiration, isTokenExpired, verifyCodeChallenge };
|
|
721
|
+
//# sourceMappingURL=index.js.map
|
|
722
|
+
//# sourceMappingURL=index.js.map
|