@c9up/warden 0.1.22 → 0.1.23
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 +1 -1
- package/dist/AuthManager.d.ts +14 -3
- package/dist/AuthManager.d.ts.map +1 -1
- package/dist/AuthManager.js +32 -4
- package/dist/AuthManager.js.map +1 -1
- package/dist/Authenticator.d.ts +48 -4
- package/dist/Authenticator.d.ts.map +1 -1
- package/dist/Authenticator.js +234 -25
- package/dist/Authenticator.js.map +1 -1
- package/dist/config.d.ts +8 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js.map +1 -1
- package/dist/strategies/SessionStrategy.d.ts +37 -25
- package/dist/strategies/SessionStrategy.d.ts.map +1 -1
- package/dist/strategies/SessionStrategy.js +43 -44
- package/dist/strategies/SessionStrategy.js.map +1 -1
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +68 -6
- package/src/Authenticator.ts +298 -27
- package/src/config.ts +8 -1
- package/src/index.ts +9 -11
- package/src/middleware.ts +17 -0
- package/src/strategies/SessionStrategy.ts +73 -46
- package/dist/firstcontact/FirstContactManager.d.ts +0 -25
- package/dist/firstcontact/FirstContactManager.d.ts.map +0 -1
- package/dist/firstcontact/FirstContactManager.js +0 -38
- package/dist/firstcontact/FirstContactManager.js.map +0 -1
- package/dist/firstcontact/drivers/GitHubDriver.d.ts +0 -14
- package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +0 -1
- package/dist/firstcontact/drivers/GitHubDriver.js +0 -60
- package/dist/firstcontact/drivers/GitHubDriver.js.map +0 -1
- package/dist/firstcontact/drivers/GoogleDriver.d.ts +0 -14
- package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +0 -1
- package/dist/firstcontact/drivers/GoogleDriver.js +0 -63
- package/dist/firstcontact/drivers/GoogleDriver.js.map +0 -1
- package/dist/firstcontact/types.d.ts +0 -44
- package/dist/firstcontact/types.d.ts.map +0 -1
- package/dist/firstcontact/types.js +0 -22
- package/dist/firstcontact/types.js.map +0 -1
- package/src/firstcontact/FirstContactManager.ts +0 -54
- package/src/firstcontact/drivers/GitHubDriver.ts +0 -77
- package/src/firstcontact/drivers/GoogleDriver.ts +0 -80
- package/src/firstcontact/types.ts +0 -62
package/src/Authenticator.ts
CHANGED
|
@@ -23,7 +23,11 @@ import type {
|
|
|
23
23
|
import { E_UNAUTHORIZED_ACCESS, WardenError } from "./errors.js";
|
|
24
24
|
import type { WardenContext } from "./middleware.js";
|
|
25
25
|
import { sanitizePayload } from "./sanitize.js";
|
|
26
|
-
import
|
|
26
|
+
import {
|
|
27
|
+
createSessionGuardState,
|
|
28
|
+
type SessionGuardState,
|
|
29
|
+
type SessionStore,
|
|
30
|
+
} from "./strategies/SessionStrategy.js";
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Guard names Warden accepts for the API-key / access-tokens driver. AdonisJS
|
|
@@ -39,6 +43,66 @@ interface StrategyWithContext extends AuthStrategy {
|
|
|
39
43
|
verifyWithContext(token: string, ctx: unknown): Promise<AuthResult>;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The strategy behind a guard name, or `undefined` when nothing is registered
|
|
48
|
+
* under it.
|
|
49
|
+
*
|
|
50
|
+
* `getStrategy()` throws for an unknown name — correct when an application
|
|
51
|
+
* asks for a guard by name, wrong here: this is a capability probe across a
|
|
52
|
+
* list, and a name that resolves to nothing simply is not a session guard.
|
|
53
|
+
*/
|
|
54
|
+
function strategyOrUndefined(
|
|
55
|
+
auth: AuthManager,
|
|
56
|
+
name: string,
|
|
57
|
+
): AuthStrategy | undefined {
|
|
58
|
+
try {
|
|
59
|
+
return auth.getStrategy(name);
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A guard that can keep a user signed in: mint a token, name its cookie, read
|
|
67
|
+
* one back, and seat the session that follows.
|
|
68
|
+
*/
|
|
69
|
+
interface RememberMeIssuer {
|
|
70
|
+
issueRememberMeToken(user: UserPayload): Promise<string | null>;
|
|
71
|
+
authenticateViaRememberMeToken(
|
|
72
|
+
cookieValue: unknown,
|
|
73
|
+
state?: SessionGuardState,
|
|
74
|
+
): Promise<{ user: UserPayload; cookieValue: string } | null>;
|
|
75
|
+
seatSession(user: UserPayload, session: SessionStore): void;
|
|
76
|
+
revokeRememberMeToken(cookieValue: unknown): Promise<void>;
|
|
77
|
+
readonly rememberMeCookieName: string;
|
|
78
|
+
readonly rememberMeAgeSeconds: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Structural, like every other capability probe here: a guard an application
|
|
83
|
+
* wrote itself, carrying the same three members, keeps users signed in just as
|
|
84
|
+
* well as the one shipped with the package.
|
|
85
|
+
*/
|
|
86
|
+
function isRememberMeIssuer(
|
|
87
|
+
strategy: AuthStrategy,
|
|
88
|
+
): strategy is AuthStrategy & RememberMeIssuer {
|
|
89
|
+
return (
|
|
90
|
+
typeof Reflect.get(strategy, "issueRememberMeToken") === "function" &&
|
|
91
|
+
typeof Reflect.get(strategy, "authenticateViaRememberMeToken") ===
|
|
92
|
+
"function" &&
|
|
93
|
+
typeof Reflect.get(strategy, "seatSession") === "function" &&
|
|
94
|
+
typeof Reflect.get(strategy, "revokeRememberMeToken") === "function" &&
|
|
95
|
+
typeof Reflect.get(strategy, "rememberMeCookieName") === "string" &&
|
|
96
|
+
typeof Reflect.get(strategy, "rememberMeAgeSeconds") === "number"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether the guard behind `name` authenticates from the request context. */
|
|
101
|
+
function isSessionGuard(auth: AuthManager, name: string): boolean {
|
|
102
|
+
const strategy = strategyOrUndefined(auth, name);
|
|
103
|
+
return strategy !== undefined && hasVerifyWithContext(strategy);
|
|
104
|
+
}
|
|
105
|
+
|
|
42
106
|
function hasVerifyWithContext(
|
|
43
107
|
strategy: AuthStrategy,
|
|
44
108
|
): strategy is StrategyWithContext {
|
|
@@ -115,7 +179,6 @@ export async function tryAuthenticate(
|
|
|
115
179
|
bearerToken: string;
|
|
116
180
|
apiKey: string;
|
|
117
181
|
session: SessionStore | undefined;
|
|
118
|
-
hasSessionStrategy: boolean;
|
|
119
182
|
},
|
|
120
183
|
): Promise<AuthAttempt> {
|
|
121
184
|
const { bearerToken, apiKey, session } = creds;
|
|
@@ -126,21 +189,22 @@ export async function tryAuthenticate(
|
|
|
126
189
|
for (const strategyName of strategies) {
|
|
127
190
|
try {
|
|
128
191
|
let r: AuthResult;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
192
|
+
// A session guard is one that can verify FROM THE REQUEST CONTEXT,
|
|
193
|
+
// not one registered under a particular name. `guards: { web:
|
|
194
|
+
// sessionGuard(...) }` is the documented config shape, and matching
|
|
195
|
+
// on the literal "session" sent it down the bearer-token path — so
|
|
196
|
+
// `auth.use('web').authenticate()` never read the session at all.
|
|
197
|
+
const strategy = strategyOrUndefined(auth, strategyName);
|
|
198
|
+
const verifyWithContext =
|
|
199
|
+
strategy && hasVerifyWithContext(strategy)
|
|
200
|
+
? strategy.verifyWithContext
|
|
201
|
+
: undefined;
|
|
202
|
+
if (verifyWithContext !== undefined) {
|
|
203
|
+
attemptCount++;
|
|
204
|
+
r = await verifyWithContext.call(strategy, "", { session });
|
|
205
|
+
// The session path bypasses AuthManager.verify(), so apply the
|
|
206
|
+
// same prototype-pollution guard JWT / api-key users get there.
|
|
207
|
+
if (r.user) sanitizePayload(r.user);
|
|
144
208
|
} else {
|
|
145
209
|
// Native-first credential, other transport as fallback so a
|
|
146
210
|
// single-credential client still authenticates (and an invalid
|
|
@@ -185,6 +249,12 @@ export class GuardAccessor {
|
|
|
185
249
|
readonly #auth: AuthManager;
|
|
186
250
|
readonly #name: string;
|
|
187
251
|
readonly #parent: Authenticator;
|
|
252
|
+
/**
|
|
253
|
+
* The session-guard flags for THIS request. They live on the accessor —
|
|
254
|
+
* which the Authenticator builds and caches per request — and never on the
|
|
255
|
+
* strategy, which is built once from config and shared by every request.
|
|
256
|
+
*/
|
|
257
|
+
readonly #state: SessionGuardState = createSessionGuardState();
|
|
188
258
|
|
|
189
259
|
constructor(
|
|
190
260
|
ctx: WardenContext,
|
|
@@ -209,19 +279,203 @@ export class GuardAccessor {
|
|
|
209
279
|
return this.user !== undefined;
|
|
210
280
|
}
|
|
211
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Whether the user was revived from a remember-me cookie rather than
|
|
284
|
+
* signing in.
|
|
285
|
+
*
|
|
286
|
+
* This is the distinction that lets an app demand the password again before
|
|
287
|
+
* something sensitive — changing an email, spending money, deleting an
|
|
288
|
+
* account.
|
|
289
|
+
*/
|
|
290
|
+
get viaRemember(): boolean {
|
|
291
|
+
return this.#state.viaRemember;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Whether a remember-me cookie was tried at all on this request. */
|
|
295
|
+
get attemptedViaRemember(): boolean {
|
|
296
|
+
return this.#state.attemptedViaRemember;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether `logout()` has run during this request.
|
|
301
|
+
*
|
|
302
|
+
* A handler that logs out and then keeps working — clearing a cart, writing
|
|
303
|
+
* an audit line — could not otherwise tell the session was already gone.
|
|
304
|
+
*/
|
|
305
|
+
get isLoggedOut(): boolean {
|
|
306
|
+
return this.#state.isLoggedOut;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Revive the user from a remember-me cookie, recording on this request that
|
|
311
|
+
* one was tried and whether it worked.
|
|
312
|
+
*
|
|
313
|
+
* The returned `cookieValue` must replace the one the browser holds: the
|
|
314
|
+
* token is single-use and is recycled on every successful revival.
|
|
315
|
+
*/
|
|
316
|
+
authenticateViaRememberMeToken(
|
|
317
|
+
cookieValue: unknown,
|
|
318
|
+
): Promise<{ user: UserPayload; cookieValue: string } | null> {
|
|
319
|
+
return this.#auth.authenticateViaRememberMeToken(
|
|
320
|
+
cookieValue,
|
|
321
|
+
this.#name,
|
|
322
|
+
this.#state,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
212
326
|
/** Authenticate the request using only this guard (throws on failure). */
|
|
213
327
|
authenticate(): Promise<void> {
|
|
214
328
|
return this.#parent.authenticateUsing([this.#name]);
|
|
215
329
|
}
|
|
216
330
|
|
|
217
|
-
/**
|
|
218
|
-
|
|
219
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Revive this request from the remember-me cookie, if the browser holds one.
|
|
333
|
+
*
|
|
334
|
+
* The token is single-use: a success recycles the cookie and re-seats the
|
|
335
|
+
* session, so a stolen copy stops working the moment the real user comes
|
|
336
|
+
* back, and the rest of the request sees an ordinary signed-in user.
|
|
337
|
+
*/
|
|
338
|
+
async tryRememberMeCookie(): Promise<UserPayload | undefined> {
|
|
339
|
+
const read = this.#ctx.request.encryptedCookie;
|
|
340
|
+
const write = this.#ctx.response.encryptedCookie;
|
|
341
|
+
const session = this.#ctx.session;
|
|
342
|
+
if (!read || !write || !session) return undefined;
|
|
343
|
+
|
|
344
|
+
const strategy = strategyOrUndefined(this.#auth, this.#name);
|
|
345
|
+
if (!strategy || !isRememberMeIssuer(strategy)) return undefined;
|
|
346
|
+
|
|
347
|
+
const cookie = read.call(this.#ctx.request, strategy.rememberMeCookieName);
|
|
348
|
+
if (!cookie) return undefined;
|
|
349
|
+
|
|
350
|
+
const revived = await strategy.authenticateViaRememberMeToken(
|
|
351
|
+
cookie,
|
|
352
|
+
this.#state,
|
|
353
|
+
);
|
|
354
|
+
if (!revived) return undefined;
|
|
355
|
+
|
|
356
|
+
write.call(
|
|
357
|
+
this.#ctx.response,
|
|
358
|
+
strategy.rememberMeCookieName,
|
|
359
|
+
revived.cookieValue,
|
|
360
|
+
{ maxAge: strategy.rememberMeAgeSeconds, httpOnly: true },
|
|
361
|
+
);
|
|
362
|
+
// Seat the session WITHOUT `login()`: that method means a password was
|
|
363
|
+
// typed, and it clears `viaRemember` — the one thing this path exists to
|
|
364
|
+
// report, and what an app checks before letting someone change an email
|
|
365
|
+
// or spend money.
|
|
366
|
+
strategy.seatSession(revived.user, session);
|
|
367
|
+
return revived.user;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Log a user in through this guard (session guards).
|
|
372
|
+
*
|
|
373
|
+
* `remember` mints a remember-me token and writes it as an ENCRYPTED,
|
|
374
|
+
* httpOnly cookie — the cookie IS the credential, so anyone who can read it
|
|
375
|
+
* can present it. Without `remember`, any cookie the browser still holds is
|
|
376
|
+
* cleared: signing in without ticking the box has to REVOKE the standing
|
|
377
|
+
* permission, not leave it in place.
|
|
378
|
+
*/
|
|
379
|
+
async login(user: UserPayload, remember = false): Promise<void> {
|
|
380
|
+
const session = this.#requireSession();
|
|
381
|
+
const issued = remember ? await this.#issueRememberMe(user) : undefined;
|
|
382
|
+
if (!remember) this.#clearRememberMe();
|
|
383
|
+
try {
|
|
384
|
+
await this.#auth.login(user, session, this.#name, this.#state);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// The token is minted and the cookie sent before the session is
|
|
387
|
+
// seated — upstream's order, and it has to be, because the cookie
|
|
388
|
+
// belongs on the same response.
|
|
389
|
+
//
|
|
390
|
+
// NAMED DEVIATION — upstream does not roll any of this back; it has
|
|
391
|
+
// no need to, because its session write is a map assignment that
|
|
392
|
+
// cannot fail. Here `login()` also fires listeners, any of which
|
|
393
|
+
// can throw, so the window is real.
|
|
394
|
+
//
|
|
395
|
+
// And clearing the cookie is not enough: the token was PERSISTED,
|
|
396
|
+
// and its value reached the wire, where it may have been captured
|
|
397
|
+
// — a proxy log, an already-flushed response. Revoking the row is
|
|
398
|
+
// what makes a failed sign-in leave nothing usable behind.
|
|
399
|
+
if (issued !== undefined) await this.#revokeRememberMe(issued);
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Undo an issued remember-me: the stored row first, then the cookie. */
|
|
405
|
+
async #revokeRememberMe(value: string): Promise<void> {
|
|
406
|
+
const strategy = strategyOrUndefined(this.#auth, this.#name);
|
|
407
|
+
if (strategy && isRememberMeIssuer(strategy)) {
|
|
408
|
+
// Best-effort: a store that is itself down must not replace the
|
|
409
|
+
// caller's error with its own.
|
|
410
|
+
await strategy.revokeRememberMeToken(value).catch(() => undefined);
|
|
411
|
+
}
|
|
412
|
+
this.#clearRememberMe();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Mint the token and put it in the browser, or say why it cannot.
|
|
417
|
+
*
|
|
418
|
+
* Returns the minted value so a failure further along can revoke it: a
|
|
419
|
+
* persisted token nobody can reach is still a credential.
|
|
420
|
+
*/
|
|
421
|
+
async #issueRememberMe(user: UserPayload): Promise<string> {
|
|
422
|
+
const strategy = strategyOrUndefined(this.#auth, this.#name);
|
|
423
|
+
if (!strategy || !isRememberMeIssuer(strategy)) {
|
|
424
|
+
throw new WardenError(
|
|
425
|
+
"REMEMBER_ME_UNAVAILABLE",
|
|
426
|
+
`Guard '${this.#name}' cannot keep a user signed in: no remember-me tokens are configured.`,
|
|
427
|
+
{
|
|
428
|
+
hint: "Set `rememberMeTokens` on the session guard, or call login(user) without the remember flag.",
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const value = await strategy.issueRememberMeToken(user);
|
|
433
|
+
if (value === null) {
|
|
434
|
+
throw new WardenError(
|
|
435
|
+
"REMEMBER_ME_UNAVAILABLE",
|
|
436
|
+
`Guard '${this.#name}' cannot keep a user signed in: no remember-me tokens are configured.`,
|
|
437
|
+
{
|
|
438
|
+
hint: "Set `rememberMeTokens` on the session guard, or call login(user) without the remember flag.",
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const write = this.#ctx.response.encryptedCookie;
|
|
443
|
+
if (!write) {
|
|
444
|
+
throw new WardenError(
|
|
445
|
+
"REMEMBER_ME_UNAVAILABLE",
|
|
446
|
+
"This host cannot write an encrypted cookie, which is where the remember-me token lives.",
|
|
447
|
+
{
|
|
448
|
+
hint: "Use a host whose response exposes encryptedCookie(), or call login(user) without the remember flag.",
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
write.call(this.#ctx.response, strategy.rememberMeCookieName, value, {
|
|
454
|
+
maxAge: strategy.rememberMeAgeSeconds,
|
|
455
|
+
httpOnly: true,
|
|
456
|
+
});
|
|
457
|
+
} catch (err) {
|
|
458
|
+
// The row exists and the browser will never hold it: a credential
|
|
459
|
+
// nobody can reach is still one, so it does not survive the failure.
|
|
460
|
+
await strategy.revokeRememberMeToken(value).catch(() => undefined);
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
return value;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Drop whatever remember-me cookie the browser is still holding. */
|
|
467
|
+
#clearRememberMe(): void {
|
|
468
|
+
const strategy = strategyOrUndefined(this.#auth, this.#name);
|
|
469
|
+
if (!strategy || !isRememberMeIssuer(strategy)) return;
|
|
470
|
+
this.#ctx.response.clearCookie?.call(
|
|
471
|
+
this.#ctx.response,
|
|
472
|
+
strategy.rememberMeCookieName,
|
|
473
|
+
);
|
|
220
474
|
}
|
|
221
475
|
|
|
222
476
|
/** Log the current user out of this guard (session guards). */
|
|
223
477
|
logout(): Promise<void> {
|
|
224
|
-
return this.#auth.logout(this.#requireSession(), this.#name);
|
|
478
|
+
return this.#auth.logout(this.#requireSession(), this.#name, this.#state);
|
|
225
479
|
}
|
|
226
480
|
|
|
227
481
|
#requireSession(): SessionStore {
|
|
@@ -355,12 +609,29 @@ export class Authenticator {
|
|
|
355
609
|
{ guardName },
|
|
356
610
|
);
|
|
357
611
|
const creds = extractCredentials(this.#ctx, this.#auth);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
612
|
+
// Same rule as the loop above: a session guard is one that verifies from
|
|
613
|
+
// the request context. Matching the literal name meant a guard called
|
|
614
|
+
// `web` never got the login redirect a browser needs.
|
|
615
|
+
const hasSessionStrategy = names.some((name) =>
|
|
616
|
+
isSessionGuard(this.#auth, name),
|
|
617
|
+
);
|
|
618
|
+
const attempt = await tryAuthenticate(this.#auth, names, creds);
|
|
619
|
+
const { attemptCount, crashCount } = attempt;
|
|
620
|
+
let result = attempt.result;
|
|
621
|
+
let viaGuard = attempt.viaGuard;
|
|
622
|
+
|
|
623
|
+
// No credential answered, but the browser may still hold a remember-me
|
|
624
|
+
// cookie — that is what "keep me signed in" means, and nothing read it.
|
|
625
|
+
if (!result?.authenticated && hasSessionStrategy) {
|
|
626
|
+
for (const name of names) {
|
|
627
|
+
const user = await this.use(name).tryRememberMeCookie();
|
|
628
|
+
if (user) {
|
|
629
|
+
result = { authenticated: true, user };
|
|
630
|
+
viaGuard = name;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
364
635
|
|
|
365
636
|
if (result?.authenticated && result.user) {
|
|
366
637
|
this.#user = result.user;
|
package/src/config.ts
CHANGED
|
@@ -61,7 +61,14 @@ export interface JwtConfig {
|
|
|
61
61
|
* A guard entry in the AdonisJS-style config — an {@link AuthStrategy} instance,
|
|
62
62
|
* built via {@link jwtGuard}/{@link sessionGuard}/{@link apiKeyGuard}. Named a
|
|
63
63
|
* "factory" for AdonisJS symmetry (`sessionGuard({...})`), though Warden guards
|
|
64
|
-
* are shared per-app instances
|
|
64
|
+
* are shared per-app instances rather than one built per request.
|
|
65
|
+
*
|
|
66
|
+
* Because the instance is shared, a guard must hold NO per-request state: the
|
|
67
|
+
* flags a request asks about — `viaRemember`, `attemptedViaRemember`,
|
|
68
|
+
* `isLoggedOut` — live on the per-request guard the Authenticator hands out
|
|
69
|
+
* (`auth.use(name)`), and a strategy method records into the state it is
|
|
70
|
+
* given. A flag stored on the strategy would answer the next request with the
|
|
71
|
+
* previous one's truth.
|
|
65
72
|
*/
|
|
66
73
|
export type GuardFactory = AuthStrategy;
|
|
67
74
|
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,10 @@ export type {
|
|
|
44
44
|
BouncerEmitter,
|
|
45
45
|
PolicyContainerResolver,
|
|
46
46
|
} from "./bouncer/types.js";
|
|
47
|
-
export type {
|
|
47
|
+
export type {
|
|
48
|
+
GuardFactory,
|
|
49
|
+
WardenConfig,
|
|
50
|
+
} from "./config.js";
|
|
48
51
|
export {
|
|
49
52
|
apiKeyGuard,
|
|
50
53
|
basicAuthGuard,
|
|
@@ -58,15 +61,6 @@ export {
|
|
|
58
61
|
E_UNAUTHORIZED_ACCESS,
|
|
59
62
|
WardenError,
|
|
60
63
|
} from "./errors.js";
|
|
61
|
-
export { GitHubDriver } from "./firstcontact/drivers/GitHubDriver.js";
|
|
62
|
-
export { GoogleDriver } from "./firstcontact/drivers/GoogleDriver.js";
|
|
63
|
-
export { FirstContactManager } from "./firstcontact/FirstContactManager.js";
|
|
64
|
-
export type {
|
|
65
|
-
FirstContactDriver,
|
|
66
|
-
OAuthConfig,
|
|
67
|
-
OAuthToken,
|
|
68
|
-
OAuthUser,
|
|
69
|
-
} from "./firstcontact/types.js";
|
|
70
64
|
export {
|
|
71
65
|
Guard,
|
|
72
66
|
getGuardMetadata,
|
|
@@ -178,9 +172,13 @@ export {
|
|
|
178
172
|
export type { JwtClaims, JwtStrategyConfig } from "./strategies/JwtStrategy.js";
|
|
179
173
|
export { generateJwtSecret, JwtStrategy } from "./strategies/JwtStrategy.js";
|
|
180
174
|
export type {
|
|
175
|
+
SessionGuardState,
|
|
181
176
|
SessionStore,
|
|
182
177
|
SessionStrategyConfig,
|
|
183
178
|
} from "./strategies/SessionStrategy.js";
|
|
184
|
-
export {
|
|
179
|
+
export {
|
|
180
|
+
createSessionGuardState,
|
|
181
|
+
SessionStrategy,
|
|
182
|
+
} from "./strategies/SessionStrategy.js";
|
|
185
183
|
export type { BlacklistDriver } from "./TokenBlacklist.js";
|
|
186
184
|
export { MemoryBlacklistDriver, TokenBlacklist } from "./TokenBlacklist.js";
|
package/src/middleware.ts
CHANGED
|
@@ -101,6 +101,8 @@ export interface WardenContext {
|
|
|
101
101
|
request: {
|
|
102
102
|
/** Ream's `HttpContext` exposes headers as a METHOD, not a property. */
|
|
103
103
|
headers(): Record<string, string>;
|
|
104
|
+
/** Read the encrypted remember-me cookie back. Optional, like its writer. */
|
|
105
|
+
encryptedCookie?: (name: string) => string | null;
|
|
104
106
|
};
|
|
105
107
|
/**
|
|
106
108
|
* Per-request IoC resolver (Ream's `ctx.containerResolver`). Warden resolves
|
|
@@ -111,6 +113,21 @@ export interface WardenContext {
|
|
|
111
113
|
response: {
|
|
112
114
|
status: (code: number) => unknown;
|
|
113
115
|
json: (data: unknown) => void;
|
|
116
|
+
/**
|
|
117
|
+
* Write an ENCRYPTED cookie, for the remember-me token. Optional: a
|
|
118
|
+
* host without it simply cannot offer "keep me signed in", and
|
|
119
|
+
* `login(user, true)` says so rather than pretending.
|
|
120
|
+
*
|
|
121
|
+
* Encrypted and not merely signed, because the value IS the credential
|
|
122
|
+
* — anyone who reads it can present it.
|
|
123
|
+
*/
|
|
124
|
+
encryptedCookie?: (
|
|
125
|
+
name: string,
|
|
126
|
+
value: string,
|
|
127
|
+
options?: Record<string, unknown>,
|
|
128
|
+
) => unknown;
|
|
129
|
+
/** Drop a cookie (used to clear the remember-me one). */
|
|
130
|
+
clearCookie?: (name: string, options?: Record<string, unknown>) => unknown;
|
|
114
131
|
/**
|
|
115
132
|
* Redirect the response (Ream's `ctx.response.redirect`). Optional so a
|
|
116
133
|
* minimal host without it still gets the 401 JSON fallback — Warden does
|
|
@@ -65,13 +65,41 @@ export interface SessionStrategyConfig {
|
|
|
65
65
|
/** Two years, Adonis' `rememberMeTokensAge` default. */
|
|
66
66
|
const DEFAULT_REMEMBER_AGE_SECONDS = 63_072_000;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The flags a session guard reports about THE CURRENT REQUEST — whether the
|
|
70
|
+
* user was revived from a remember-me cookie, whether one was even tried, and
|
|
71
|
+
* whether `logout()` has run.
|
|
72
|
+
*
|
|
73
|
+
* They live here, in a per-request object, and NOT on the strategy: a strategy
|
|
74
|
+
* is built once from config and shared by every request the app serves, so a
|
|
75
|
+
* flag stored on it would answer the next request with the previous one's
|
|
76
|
+
* truth — a session restored from a cookie would keep looking like one long
|
|
77
|
+
* after, and a logout would make every later request read as logged out.
|
|
78
|
+
* Upstream avoids this by building a fresh guard per request; the strategy is
|
|
79
|
+
* shared here, so the state is what moves.
|
|
80
|
+
*/
|
|
81
|
+
export interface SessionGuardState {
|
|
82
|
+
/** The user was revived from a remember-me cookie rather than signing in. */
|
|
83
|
+
viaRemember: boolean;
|
|
84
|
+
/** A remember-me cookie was tried at all — whether or not it worked. */
|
|
85
|
+
attemptedViaRemember: boolean;
|
|
86
|
+
/** `logout()` has run during this request. */
|
|
87
|
+
isLoggedOut: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A fresh set of per-request flags, all false. */
|
|
91
|
+
export function createSessionGuardState(): SessionGuardState {
|
|
92
|
+
return {
|
|
93
|
+
viaRemember: false,
|
|
94
|
+
attemptedViaRemember: false,
|
|
95
|
+
isLoggedOut: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
68
99
|
export class SessionStrategy implements AuthStrategy {
|
|
69
100
|
name = "session";
|
|
70
101
|
#config: SessionStrategyConfig;
|
|
71
102
|
#sessionKey: string;
|
|
72
|
-
#viaRemember = false;
|
|
73
|
-
#loggedOut = false;
|
|
74
|
-
#attemptedViaRemember = false;
|
|
75
103
|
|
|
76
104
|
constructor(config: SessionStrategyConfig) {
|
|
77
105
|
this.#config = config;
|
|
@@ -88,37 +116,6 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
88
116
|
return this.#config.rememberMeTokens !== undefined;
|
|
89
117
|
}
|
|
90
118
|
|
|
91
|
-
/**
|
|
92
|
-
* Whether the current user was revived from a remember-me cookie rather
|
|
93
|
-
* than signing in (AdonisJS `viaRemember`).
|
|
94
|
-
*
|
|
95
|
-
* This is the distinction that lets an app demand the password again before
|
|
96
|
-
* something sensitive — changing an email, spending money, deleting an
|
|
97
|
-
* account. Nothing reported it, so a session restored from a cookie looked
|
|
98
|
-
* exactly like one where the user had just typed their password.
|
|
99
|
-
*/
|
|
100
|
-
get viaRemember(): boolean {
|
|
101
|
-
return this.#viaRemember;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Whether a remember-me token was even tried on this request (AdonisJS
|
|
106
|
-
* `attemptedViaRemember`) — true whether or not it worked.
|
|
107
|
-
*/
|
|
108
|
-
get attemptedViaRemember(): boolean {
|
|
109
|
-
return this.#attemptedViaRemember;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Whether `logout()` ran on this guard (AdonisJS `isLoggedOut`).
|
|
114
|
-
*
|
|
115
|
-
* A handler that logs out and then keeps working — clearing a cart, writing
|
|
116
|
-
* an audit line — could not tell that the session was already gone.
|
|
117
|
-
*/
|
|
118
|
-
get isLoggedOut(): boolean {
|
|
119
|
-
return this.#loggedOut;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
119
|
/** The session key the user id is stored under (AdonisJS `sessionKeyName`). */
|
|
123
120
|
get sessionKeyName(): string {
|
|
124
121
|
return this.#sessionKey;
|
|
@@ -130,6 +127,11 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
130
127
|
}
|
|
131
128
|
|
|
132
129
|
#rememberMeAge(): number {
|
|
130
|
+
return this.rememberMeAgeSeconds;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** How long a remember-me token lives, in SECONDS (the cookie's max-age). */
|
|
134
|
+
get rememberMeAgeSeconds(): number {
|
|
133
135
|
return this.#config.rememberMeAge ?? DEFAULT_REMEMBER_AGE_SECONDS;
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -155,10 +157,11 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
155
157
|
*/
|
|
156
158
|
async authenticateViaRememberMeToken(
|
|
157
159
|
cookieValue: unknown,
|
|
160
|
+
state?: SessionGuardState,
|
|
158
161
|
): Promise<{ user: UserPayload; cookieValue: string } | null> {
|
|
159
162
|
const driver = this.#config.rememberMeTokens;
|
|
160
163
|
if (!driver) return null;
|
|
161
|
-
|
|
164
|
+
if (state) state.attemptedViaRemember = true;
|
|
162
165
|
|
|
163
166
|
const recycled = await verifyAndRecycleRememberMeToken(
|
|
164
167
|
driver,
|
|
@@ -170,7 +173,7 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
170
173
|
const user = await this.#config.findUser(recycled.userId);
|
|
171
174
|
if (!user) return null;
|
|
172
175
|
|
|
173
|
-
|
|
176
|
+
if (state) state.viaRemember = true;
|
|
174
177
|
return { user, cookieValue: recycled.value };
|
|
175
178
|
}
|
|
176
179
|
|
|
@@ -240,14 +243,33 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
240
243
|
* migration is handled by Ream's `SessionMiddleware` on the response
|
|
241
244
|
* path — see `wasRegenerated()` there.
|
|
242
245
|
*/
|
|
243
|
-
|
|
246
|
+
/**
|
|
247
|
+
* Seat a user in the session, without deciding HOW they got there.
|
|
248
|
+
*
|
|
249
|
+
* Split out from {@link login} because the remember-me path needs the seat
|
|
250
|
+
* but not the flags: `login()` means a password was typed, and resetting
|
|
251
|
+
* `viaRemember` there would erase the very fact a cookie-revived session
|
|
252
|
+
* exists to report.
|
|
253
|
+
*/
|
|
254
|
+
seatSession(user: UserPayload, session: SessionStore): void {
|
|
244
255
|
session.regenerate();
|
|
245
256
|
session.put(this.#sessionKey, user.id);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async login(
|
|
260
|
+
user: UserPayload,
|
|
261
|
+
session: SessionStore,
|
|
262
|
+
state?: SessionGuardState,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
this.seatSession(user, session);
|
|
265
|
+
if (state) {
|
|
266
|
+
state.isLoggedOut = false;
|
|
267
|
+
// A password was typed: this session is no longer "via remember",
|
|
268
|
+
// even if a cookie was tried earlier in the same request. Without
|
|
269
|
+
// the reset the flag would stay true and a re-auth prompt would
|
|
270
|
+
// never fire.
|
|
271
|
+
state.viaRemember = false;
|
|
272
|
+
}
|
|
251
273
|
}
|
|
252
274
|
|
|
253
275
|
/**
|
|
@@ -267,10 +289,15 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
267
289
|
* The remember-me token is revoked separately through
|
|
268
290
|
* {@link revokeRememberMeToken}, because only the caller holds the cookie.
|
|
269
291
|
*/
|
|
270
|
-
async logout(
|
|
292
|
+
async logout(
|
|
293
|
+
session: SessionStore,
|
|
294
|
+
state?: SessionGuardState,
|
|
295
|
+
): Promise<void> {
|
|
271
296
|
session.forget(this.#sessionKey);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
297
|
+
if (state) {
|
|
298
|
+
state.viaRemember = false;
|
|
299
|
+
state.attemptedViaRemember = false;
|
|
300
|
+
state.isLoggedOut = true;
|
|
301
|
+
}
|
|
275
302
|
}
|
|
276
303
|
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FirstContactManager — OAuth2 social authentication.
|
|
3
|
-
*
|
|
4
|
-
* Usage:
|
|
5
|
-
* firstContact.use('google').redirectUrl()
|
|
6
|
-
* firstContact.use('google').callback(code)
|
|
7
|
-
*/
|
|
8
|
-
import type { FirstContactDriver, OAuthToken, OAuthUser } from "./types.js";
|
|
9
|
-
export declare class FirstContactManager {
|
|
10
|
-
#private;
|
|
11
|
-
use(name: string): FirstContactDriver;
|
|
12
|
-
register(name: string, driver: FirstContactDriver): void;
|
|
13
|
-
redirect(name: string, state?: string): string;
|
|
14
|
-
/**
|
|
15
|
-
* Handle the OAuth callback. Pass `state` (from the query string) and
|
|
16
|
-
* `expectedState` (from the session, stored at redirect time) for CSRF
|
|
17
|
-
* protection. Omitting `expectedState` logs a security warning.
|
|
18
|
-
*/
|
|
19
|
-
callback(name: string, code: string, state?: string, expectedState?: string): Promise<{
|
|
20
|
-
user: OAuthUser;
|
|
21
|
-
token: OAuthToken;
|
|
22
|
-
}>;
|
|
23
|
-
get registeredDrivers(): string[];
|
|
24
|
-
}
|
|
25
|
-
//# sourceMappingURL=FirstContactManager.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"FirstContactManager.d.ts","sourceRoot":"","sources":["../../src/firstcontact/FirstContactManager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5E,qBAAa,mBAAmB;;IAG/B,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB;IASrC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,IAAI;IAIxD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM;IAI9C;;;;OAIG;IACG,QAAQ,CACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,MAAM,EACd,aAAa,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC;QAAE,IAAI,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,UAAU,CAAA;KAAE,CAAC;IAUlD,IAAI,iBAAiB,IAAI,MAAM,EAAE,CAEhC;CACD"}
|