@c9up/warden 0.1.16 → 0.1.18
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/AuthManager.d.ts +40 -0
- package/dist/AuthManager.d.ts.map +1 -1
- package/dist/AuthManager.js +45 -3
- package/dist/AuthManager.js.map +1 -1
- package/dist/Authenticator.d.ts.map +1 -1
- package/dist/Authenticator.js +18 -2
- package/dist/Authenticator.js.map +1 -1
- package/dist/RedisBlacklistDriver.d.ts +7 -1
- package/dist/RedisBlacklistDriver.d.ts.map +1 -1
- package/dist/RedisBlacklistDriver.js +30 -5
- package/dist/RedisBlacklistDriver.js.map +1 -1
- package/dist/RememberMeToken.d.ts +99 -0
- package/dist/RememberMeToken.d.ts.map +1 -0
- package/dist/RememberMeToken.js +167 -0
- package/dist/RememberMeToken.js.map +1 -0
- package/dist/ResilientBlacklistDriver.d.ts +15 -9
- package/dist/ResilientBlacklistDriver.d.ts.map +1 -1
- package/dist/ResilientBlacklistDriver.js +53 -15
- package/dist/ResilientBlacklistDriver.js.map +1 -1
- package/dist/WardenProvider.d.ts +1 -0
- package/dist/WardenProvider.d.ts.map +1 -1
- package/dist/WardenProvider.js +64 -0
- package/dist/WardenProvider.js.map +1 -1
- package/dist/bouncer/Bouncer.d.ts +32 -1
- package/dist/bouncer/Bouncer.d.ts.map +1 -1
- package/dist/bouncer/Bouncer.js +54 -0
- package/dist/bouncer/Bouncer.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +1 -1
- package/dist/firstcontact/drivers/GitHubDriver.js +2 -3
- package/dist/firstcontact/drivers/GitHubDriver.js.map +1 -1
- package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +1 -1
- package/dist/firstcontact/drivers/GoogleDriver.js +2 -3
- package/dist/firstcontact/drivers/GoogleDriver.js.map +1 -1
- package/dist/firstcontact/types.d.ts +10 -0
- package/dist/firstcontact/types.d.ts.map +1 -1
- package/dist/firstcontact/types.js +18 -1
- package/dist/firstcontact/types.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/mfa/MfaManager.d.ts.map +1 -1
- package/dist/mfa/MfaManager.js +14 -5
- package/dist/mfa/MfaManager.js.map +1 -1
- package/dist/mfa/TotpProvider.d.ts +34 -1
- package/dist/mfa/TotpProvider.d.ts.map +1 -1
- package/dist/mfa/TotpProvider.js +60 -4
- package/dist/mfa/TotpProvider.js.map +1 -1
- package/dist/mfa/webauthn-codec.d.ts +6 -1
- package/dist/mfa/webauthn-codec.d.ts.map +1 -1
- package/dist/mfa/webauthn-codec.js +50 -6
- package/dist/mfa/webauthn-codec.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +46 -1
- package/dist/middleware.js.map +1 -1
- package/dist/quasar.d.ts +18 -0
- package/dist/quasar.d.ts.map +1 -0
- package/dist/quasar.js +56 -0
- package/dist/quasar.js.map +1 -0
- package/dist/strategies/ApiKeyStrategy.d.ts +10 -1
- package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
- package/dist/strategies/ApiKeyStrategy.js +16 -0
- package/dist/strategies/ApiKeyStrategy.js.map +1 -1
- package/dist/strategies/BasicAuthStrategy.d.ts +59 -0
- package/dist/strategies/BasicAuthStrategy.d.ts.map +1 -0
- package/dist/strategies/BasicAuthStrategy.js +97 -0
- package/dist/strategies/BasicAuthStrategy.js.map +1 -0
- package/dist/strategies/JwtStrategy.d.ts +10 -1
- package/dist/strategies/JwtStrategy.d.ts.map +1 -1
- package/dist/strategies/JwtStrategy.js +11 -0
- package/dist/strategies/JwtStrategy.js.map +1 -1
- package/dist/strategies/SessionStrategy.d.ts +58 -2
- package/dist/strategies/SessionStrategy.d.ts.map +1 -1
- package/dist/strategies/SessionStrategy.js +75 -1
- package/dist/strategies/SessionStrategy.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +9 -3
- package/src/AuthManager.ts +80 -0
- package/src/Authenticator.ts +22 -3
- package/src/RedisBlacklistDriver.ts +39 -5
- package/src/RememberMeToken.ts +223 -0
- package/src/ResilientBlacklistDriver.ts +58 -18
- package/src/WardenProvider.ts +90 -0
- package/src/bouncer/Bouncer.ts +61 -1
- package/src/config.ts +9 -0
- package/src/firstcontact/drivers/GitHubDriver.ts +2 -3
- package/src/firstcontact/drivers/GoogleDriver.ts +2 -3
- package/src/firstcontact/types.ts +24 -0
- package/src/index.ts +25 -2
- package/src/mfa/MfaManager.ts +15 -8
- package/src/mfa/TotpProvider.ts +90 -6
- package/src/mfa/webauthn-codec.ts +48 -6
- package/src/middleware.ts +55 -2
- package/src/quasar.ts +78 -0
- package/src/strategies/ApiKeyStrategy.ts +23 -1
- package/src/strategies/BasicAuthStrategy.ts +127 -0
- package/src/strategies/JwtStrategy.ts +18 -1
- package/src/strategies/SessionStrategy.ts +113 -2
|
@@ -48,11 +48,31 @@ interface Decoded {
|
|
|
48
48
|
next: number;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How deep a nested item may go.
|
|
53
|
+
*
|
|
54
|
+
* Every item here is attacker-supplied: a registration payload is whatever the
|
|
55
|
+
* browser posted. Without a limit, a run of tag bytes recurses once per byte
|
|
56
|
+
* and overflows the stack — 200 KB was enough to kill the request.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_DEPTH = 32;
|
|
59
|
+
|
|
51
60
|
/**
|
|
52
61
|
* Decode a single CBOR item starting at `start`. Definite-length only —
|
|
53
62
|
* indefinite-length items and floats are rejected (WebAuthn uses neither).
|
|
63
|
+
*
|
|
64
|
+
* Every declared length is checked against the bytes actually left. A CBOR
|
|
65
|
+
* header can claim up to 2^64 items in five bytes; believing it meant looping
|
|
66
|
+
* billions of times over a buffer that ended long ago, which cost seconds of
|
|
67
|
+
* blocked event loop per request.
|
|
54
68
|
*/
|
|
55
|
-
export function decodeCbor(buf: Buffer, start = 0): Decoded {
|
|
69
|
+
export function decodeCbor(buf: Buffer, start = 0, depth = 0): Decoded {
|
|
70
|
+
if (depth > MAX_DEPTH) {
|
|
71
|
+
throw new Error(`CBOR: nesting deeper than ${MAX_DEPTH} is not supported`);
|
|
72
|
+
}
|
|
73
|
+
if (start < 0 || start >= buf.length) {
|
|
74
|
+
throw new Error("CBOR: item starts past the end of the buffer");
|
|
75
|
+
}
|
|
56
76
|
const major = buf[start] >> 5;
|
|
57
77
|
const info = buf[start] & 0x1f;
|
|
58
78
|
let len: number;
|
|
@@ -60,36 +80,56 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
|
|
|
60
80
|
if (info < 24) {
|
|
61
81
|
len = info;
|
|
62
82
|
} else if (info === 24) {
|
|
83
|
+
if (p >= buf.length) throw new Error("CBOR: truncated length");
|
|
63
84
|
len = buf[p];
|
|
64
85
|
p += 1;
|
|
65
86
|
} else if (info === 25) {
|
|
87
|
+
if (p + 2 > buf.length) throw new Error("CBOR: truncated length");
|
|
66
88
|
len = buf.readUInt16BE(p);
|
|
67
89
|
p += 2;
|
|
68
90
|
} else if (info === 26) {
|
|
91
|
+
if (p + 4 > buf.length) throw new Error("CBOR: truncated length");
|
|
69
92
|
len = buf.readUInt32BE(p);
|
|
70
93
|
p += 4;
|
|
71
94
|
} else if (info === 27) {
|
|
72
|
-
|
|
95
|
+
if (p + 8 > buf.length) throw new Error("CBOR: truncated length");
|
|
96
|
+
const wide = buf.readBigUInt64BE(p);
|
|
97
|
+
if (wide > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
98
|
+
throw new Error("CBOR: length exceeds the safe integer range");
|
|
99
|
+
}
|
|
100
|
+
len = Number(wide);
|
|
73
101
|
p += 8;
|
|
74
102
|
} else {
|
|
75
103
|
throw new Error("CBOR: indefinite or reserved length is not supported");
|
|
76
104
|
}
|
|
77
105
|
|
|
106
|
+
/** Reject a declared count the remaining bytes cannot possibly hold. */
|
|
107
|
+
const fits = (perItem: number): void => {
|
|
108
|
+
if (len > (buf.length - p) / perItem) {
|
|
109
|
+
throw new Error("CBOR: declared length exceeds the remaining bytes");
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
78
113
|
switch (major) {
|
|
79
114
|
case 0: // unsigned int
|
|
80
115
|
return { value: len, next: p };
|
|
81
116
|
case 1: // negative int
|
|
82
117
|
return { value: -1 - len, next: p };
|
|
83
118
|
case 2: // byte string
|
|
119
|
+
fits(1);
|
|
84
120
|
return { value: buf.subarray(p, p + len), next: p + len };
|
|
85
121
|
case 3: // text string
|
|
122
|
+
fits(1);
|
|
86
123
|
return { value: buf.toString("utf8", p, p + len), next: p + len };
|
|
87
124
|
case 4: {
|
|
88
125
|
// array
|
|
126
|
+
// One item is one byte at minimum, so a count above the bytes left
|
|
127
|
+
// cannot be honest.
|
|
128
|
+
fits(1);
|
|
89
129
|
const arr: CborValue[] = [];
|
|
90
130
|
let cur = p;
|
|
91
131
|
for (let i = 0; i < len; i++) {
|
|
92
|
-
const d = decodeCbor(buf, cur);
|
|
132
|
+
const d = decodeCbor(buf, cur, depth + 1);
|
|
93
133
|
arr.push(d.value);
|
|
94
134
|
cur = d.next;
|
|
95
135
|
}
|
|
@@ -97,12 +137,14 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
|
|
|
97
137
|
}
|
|
98
138
|
case 5: {
|
|
99
139
|
// map
|
|
140
|
+
// A pair is two bytes at minimum.
|
|
141
|
+
fits(2);
|
|
100
142
|
const map: CborMap = new Map();
|
|
101
143
|
let cur = p;
|
|
102
144
|
for (let i = 0; i < len; i++) {
|
|
103
|
-
const k = decodeCbor(buf, cur);
|
|
145
|
+
const k = decodeCbor(buf, cur, depth + 1);
|
|
104
146
|
cur = k.next;
|
|
105
|
-
const v = decodeCbor(buf, cur);
|
|
147
|
+
const v = decodeCbor(buf, cur, depth + 1);
|
|
106
148
|
cur = v.next;
|
|
107
149
|
if (typeof k.value !== "number" && typeof k.value !== "string") {
|
|
108
150
|
throw new Error("CBOR: only integer/text map keys are supported");
|
|
@@ -113,7 +155,7 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
|
|
|
113
155
|
}
|
|
114
156
|
case 6: {
|
|
115
157
|
// tag — decode and surface the tagged content
|
|
116
|
-
const d = decodeCbor(buf, p);
|
|
158
|
+
const d = decodeCbor(buf, p, depth + 1);
|
|
117
159
|
return { value: d.value, next: d.next };
|
|
118
160
|
}
|
|
119
161
|
case 7:
|
package/src/middleware.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { Authenticator } from "./Authenticator.js";
|
|
|
20
20
|
import { AuthManager, type UserPayload } from "./AuthManager.js";
|
|
21
21
|
import type { BasePolicy } from "./bouncer/BasePolicy.js";
|
|
22
22
|
import { Bouncer } from "./bouncer/Bouncer.js";
|
|
23
|
-
import type { Ability } from "./bouncer/types.js";
|
|
23
|
+
import type { Ability, PolicyContainerResolver } from "./bouncer/types.js";
|
|
24
24
|
import type { ScopeRequestContext } from "./config.js";
|
|
25
25
|
import { E_UNAUTHORIZED_ACCESS, WardenError } from "./errors.js";
|
|
26
26
|
import {
|
|
@@ -56,6 +56,33 @@ interface ContainerResolver {
|
|
|
56
56
|
make(token: ResolvableToken): Promise<unknown>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Adapt the request resolver to the shape the Bouncer needs for policy DI.
|
|
61
|
+
*
|
|
62
|
+
* The context resolver is deliberately untyped (`Promise<unknown>`) — it
|
|
63
|
+
* resolves string tokens too. `instanceof` is what proves the instance is the
|
|
64
|
+
* policy that was asked for: a real check rather than an assertion, and it
|
|
65
|
+
* catches a host resolver that hands back something else instead of letting a
|
|
66
|
+
* wrong object reach the policy's methods.
|
|
67
|
+
*/
|
|
68
|
+
function policyResolver(
|
|
69
|
+
resolver: ContainerResolver | undefined,
|
|
70
|
+
): PolicyContainerResolver | undefined {
|
|
71
|
+
if (resolver === undefined) return undefined;
|
|
72
|
+
return {
|
|
73
|
+
async make<T>(ctor: new (...args: never[]) => T): Promise<T> {
|
|
74
|
+
const instance = await resolver.make(ctor);
|
|
75
|
+
if (!(instance instanceof ctor)) {
|
|
76
|
+
throw new WardenError(
|
|
77
|
+
"POLICY_RESOLUTION_FAILED",
|
|
78
|
+
`The container resolved "${ctor.name}" to something else — a policy must be an instance of the class that was requested.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return instance;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
/**
|
|
60
87
|
* Agnostic authorization slot — structurally Ream's `Authorizer` interface (and
|
|
61
88
|
* Adonis's bouncer contract): `allows` / `denies` / `authorize`. Typed as the
|
|
@@ -317,6 +344,19 @@ export async function silentAuth(ctx: WardenContext, next: WardenNext) {
|
|
|
317
344
|
await next();
|
|
318
345
|
}
|
|
319
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Merge values into the request's view state, when the app has a template
|
|
349
|
+
* layer. A no-op outside an HTTP render, or in an app with no views.
|
|
350
|
+
*/
|
|
351
|
+
function shareWithView(
|
|
352
|
+
ctx: WardenContext,
|
|
353
|
+
values: Record<string, unknown>,
|
|
354
|
+
): void {
|
|
355
|
+
const view = Reflect.get(Object(ctx), "view");
|
|
356
|
+
const share = Reflect.get(Object(view), "share");
|
|
357
|
+
if (typeof share === "function") share.call(view, values);
|
|
358
|
+
}
|
|
359
|
+
|
|
320
360
|
/**
|
|
321
361
|
* Reuse the request's Authenticator if one is already attached (e.g. `silentAuth`
|
|
322
362
|
* ran first), else build and attach a fresh one. Keeps `ctx.auth` a single
|
|
@@ -329,6 +369,10 @@ function ensureAuthenticator(
|
|
|
329
369
|
if (ctx.auth instanceof Authenticator) return ctx.auth;
|
|
330
370
|
const authenticator = new Authenticator(ctx, auth);
|
|
331
371
|
ctx.auth = authenticator;
|
|
372
|
+
// Share it with the request's view, as AdonisJS's auth does, so a migrated
|
|
373
|
+
// template reads `{{ auth.user.email }}` / `@if(auth.isAuthenticated)`
|
|
374
|
+
// unchanged. Both middlewares funnel through here, so one share covers them.
|
|
375
|
+
shareWithView(ctx, { auth: authenticator });
|
|
332
376
|
return authenticator;
|
|
333
377
|
}
|
|
334
378
|
|
|
@@ -405,10 +449,19 @@ export async function initializeBouncer(ctx: WardenContext, next: WardenNext) {
|
|
|
405
449
|
? await registry.resolveScope(toScopeContext(ctx))
|
|
406
450
|
: "global";
|
|
407
451
|
|
|
408
|
-
|
|
452
|
+
const bouncer = new Bouncer(user, registry?.abilities, registry?.policies, {
|
|
409
453
|
scope,
|
|
410
454
|
resolver,
|
|
455
|
+
// The REQUEST's resolver, so a policy taking constructor dependencies
|
|
456
|
+
// gets them — and gets this request's, not the application container's.
|
|
457
|
+
// It was never passed, so `@inject()` on a policy silently resolved to a
|
|
458
|
+
// plain `new Policy()` with no dependencies at all.
|
|
459
|
+
containerResolver: policyResolver(ctx.containerResolver),
|
|
411
460
|
});
|
|
461
|
+
ctx.bouncer = bouncer;
|
|
462
|
+
// Share the checks with the request's view so `@can(...)` resolves them,
|
|
463
|
+
// as AdonisJS's own middleware does.
|
|
464
|
+
shareWithView(ctx, bouncer.templateHelpers);
|
|
412
465
|
await next();
|
|
413
466
|
}
|
|
414
467
|
|
package/src/quasar.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a Redis connection by name, from `@c9up/quasar`.
|
|
3
|
+
*
|
|
4
|
+
* Warden does not depend on quasar: it is an optional peer, and this module
|
|
5
|
+
* never imports it statically. The specifier is built at runtime so the
|
|
6
|
+
* TypeScript build stays free of it too — a hard type import would make echo
|
|
7
|
+
* unbuildable for anyone whose blacklist is in memory.
|
|
8
|
+
*
|
|
9
|
+
* The shape is checked before use rather than asserted, the same way warden
|
|
10
|
+
* duck-types its host framework.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { RedisLikeClient } from "./RedisBlacklistDriver.js";
|
|
14
|
+
|
|
15
|
+
/** The slice of quasar's manager this needs: a connection, by name. */
|
|
16
|
+
interface ConnectionSource {
|
|
17
|
+
connection(name?: string): unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isConnectionSource(value: unknown): value is ConnectionSource {
|
|
21
|
+
return (
|
|
22
|
+
typeof value === "object" &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
typeof Reflect.get(value, "connection") === "function"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRedisLikeClient(value: unknown): value is RedisLikeClient {
|
|
29
|
+
if (typeof value !== "object" || value === null) return false;
|
|
30
|
+
// The commands this driver actually issues — the whole of `RedisLikeClient`.
|
|
31
|
+
// A connection missing one would fail on the first revocation, far from the
|
|
32
|
+
// cause. Nothing more: the driver documents itself as needing only `set`
|
|
33
|
+
// with an "EX" ttl and `exists`, so demanding sets or key scans here would
|
|
34
|
+
// reject a client that works perfectly well.
|
|
35
|
+
const required = ["set", "exists"];
|
|
36
|
+
return required.every(
|
|
37
|
+
(name) => typeof Reflect.get(value, name) === "function",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A resolver for `new RedisBlacklistDriver(quasarConnection())` — quasar is
|
|
43
|
+
* loaded on the first revocation check, not at config time.
|
|
44
|
+
*/
|
|
45
|
+
export function quasarConnection(
|
|
46
|
+
name?: string,
|
|
47
|
+
): () => Promise<RedisLikeClient> {
|
|
48
|
+
return async () => {
|
|
49
|
+
const specifier = "@c9up/quasar/services/main";
|
|
50
|
+
let loaded: unknown;
|
|
51
|
+
try {
|
|
52
|
+
loaded = await import(/* @vite-ignore */ specifier);
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Warden: the "${name ?? "default"}" blacklist asks for a quasar connection, but @c9up/quasar is not installed.\n` +
|
|
56
|
+
" pnpm add @c9up/quasar",
|
|
57
|
+
{ cause },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const manager = isConnectionSource(loaded)
|
|
62
|
+
? loaded
|
|
63
|
+
: Reflect.get(Object(loaded), "default");
|
|
64
|
+
if (!isConnectionSource(manager)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Warden: @c9up/quasar/services/main did not expose a connection() manager",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const connection = manager.connection(name);
|
|
71
|
+
if (!isRedisLikeClient(connection)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Warden: quasar connection "${name ?? "default"}" does not carry the commands this blacklist needs`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return connection;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* @implements MISS-8
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
AuthClientResponse,
|
|
11
|
+
AuthResult,
|
|
12
|
+
AuthStrategy,
|
|
13
|
+
UserPayload,
|
|
14
|
+
} from "../AuthManager.js";
|
|
10
15
|
|
|
11
16
|
export interface ApiKeyConfig {
|
|
12
17
|
headerName?: string;
|
|
@@ -42,6 +47,23 @@ export class ApiKeyStrategy implements AuthStrategy {
|
|
|
42
47
|
);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The header a test client sends to present `key` (AdonisJS
|
|
52
|
+
* `authenticateAsClient`).
|
|
53
|
+
*
|
|
54
|
+
* Takes the KEY, not a user: an API key is issued out of band, so a test
|
|
55
|
+
* seeds one through `findByKey` and hands that same key here — inventing a
|
|
56
|
+
* key the resolver has never seen would prove nothing.
|
|
57
|
+
*/
|
|
58
|
+
authenticateAsClient(key: string): AuthClientResponse {
|
|
59
|
+
return {
|
|
60
|
+
headers: {
|
|
61
|
+
authorization: `Bearer ${key}`,
|
|
62
|
+
[this.#headerName]: key,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
async verify(token: string): Promise<AuthResult> {
|
|
46
68
|
const result = await this.#config.findByKey(token);
|
|
47
69
|
if (!result) return { authenticated: false, error: "Invalid API key" };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Basic authentication (AdonisJS `basic_auth` guard).
|
|
3
|
+
*
|
|
4
|
+
* The browser prompts for credentials and resends them on every request; there
|
|
5
|
+
* is no session and nothing to log out of. That makes it the right fit for a
|
|
6
|
+
* machine-to-machine endpoint or an internal tool behind TLS, and the wrong fit
|
|
7
|
+
* for a user-facing login — the credentials travel on EVERY request, so without
|
|
8
|
+
* TLS they are exposed on every one of them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { timingSafeEqual } from "node:crypto";
|
|
12
|
+
import type {
|
|
13
|
+
AuthClientResponse,
|
|
14
|
+
AuthResult,
|
|
15
|
+
AuthStrategy,
|
|
16
|
+
UserPayload,
|
|
17
|
+
} from "../AuthManager.js";
|
|
18
|
+
|
|
19
|
+
export interface BasicAuthConfig {
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a user by the username half of the header, and say whether the
|
|
22
|
+
* password matches. Returning null is an unknown user.
|
|
23
|
+
*/
|
|
24
|
+
verifyCredentials: (
|
|
25
|
+
uid: string,
|
|
26
|
+
password: string,
|
|
27
|
+
) => Promise<UserPayload | null>;
|
|
28
|
+
/**
|
|
29
|
+
* The realm announced in `WWW-Authenticate`. The browser shows it in its
|
|
30
|
+
* prompt, so it should name what is being unlocked.
|
|
31
|
+
*/
|
|
32
|
+
realm?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The `WWW-Authenticate` value a 401 must carry for the browser to prompt. */
|
|
36
|
+
export function basicAuthChallenge(realm = "Restricted"): string {
|
|
37
|
+
// The realm is quoted, so a quote inside it would end the field early.
|
|
38
|
+
return `Basic realm="${realm.replace(/["\\]/g, "")}", charset="UTF-8"`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Split an `Authorization: Basic` header into its two halves.
|
|
43
|
+
*
|
|
44
|
+
* RFC 7617 splits on the FIRST colon: a password may contain colons, a username
|
|
45
|
+
* may not. Splitting on the last one would silently accept the wrong pair.
|
|
46
|
+
*/
|
|
47
|
+
export function decodeBasicAuth(
|
|
48
|
+
header: string | undefined,
|
|
49
|
+
): { uid: string; password: string } | null {
|
|
50
|
+
if (!header) return null;
|
|
51
|
+
const [scheme, encoded] = header.split(" ");
|
|
52
|
+
if ((scheme ?? "").toLowerCase() !== "basic" || !encoded) return null;
|
|
53
|
+
let decoded: string;
|
|
54
|
+
try {
|
|
55
|
+
decoded = Buffer.from(encoded, "base64").toString("utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const separator = decoded.indexOf(":");
|
|
60
|
+
if (separator === -1) return null;
|
|
61
|
+
return {
|
|
62
|
+
uid: decoded.slice(0, separator),
|
|
63
|
+
password: decoded.slice(separator + 1),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class BasicAuthStrategy implements AuthStrategy {
|
|
68
|
+
name = "basic_auth";
|
|
69
|
+
readonly #config: BasicAuthConfig;
|
|
70
|
+
|
|
71
|
+
constructor(config: BasicAuthConfig) {
|
|
72
|
+
this.#config = config;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The challenge to send with a 401 so the browser prompts. */
|
|
76
|
+
get challenge(): string {
|
|
77
|
+
return basicAuthChallenge(this.#config.realm);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Authenticate a `{ uid, password }` pair directly. */
|
|
81
|
+
async authenticate(
|
|
82
|
+
credentials: Record<string, unknown>,
|
|
83
|
+
): Promise<AuthResult> {
|
|
84
|
+
const uid = credentials.uid ?? credentials.username ?? credentials.email;
|
|
85
|
+
const password = credentials.password;
|
|
86
|
+
if (typeof uid !== "string" || typeof password !== "string") {
|
|
87
|
+
return { authenticated: false, error: "Invalid credentials" };
|
|
88
|
+
}
|
|
89
|
+
const user = await this.#config.verifyCredentials(uid, password);
|
|
90
|
+
// One message for an unknown user and for a wrong password: telling them
|
|
91
|
+
// apart turns the endpoint into a username oracle.
|
|
92
|
+
if (!user) return { authenticated: false, error: "Invalid credentials" };
|
|
93
|
+
return { authenticated: true, user };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Authenticate from the raw `Authorization` header value. */
|
|
97
|
+
async verify(header: string): Promise<AuthResult> {
|
|
98
|
+
const decoded = decodeBasicAuth(header);
|
|
99
|
+
if (!decoded) return { authenticated: false, error: "Invalid credentials" };
|
|
100
|
+
return this.authenticate(decoded);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the header a client sends (AdonisJS `authenticateAsClient`) — what a
|
|
105
|
+
* test uses to act as a user without reproducing the encoding by hand.
|
|
106
|
+
*/
|
|
107
|
+
authenticateAsClient(uid: string, password: string): AuthClientResponse {
|
|
108
|
+
const encoded = Buffer.from(`${uid}:${password}`, "utf8").toString(
|
|
109
|
+
"base64",
|
|
110
|
+
);
|
|
111
|
+
return { headers: { authorization: `Basic ${encoded}` } };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compare two secrets without leaking how far they matched.
|
|
117
|
+
*
|
|
118
|
+
* Exposed because an app implementing `verifyCredentials` against a plaintext
|
|
119
|
+
* shared secret needs it — a plain `===` returns sooner on an early mismatch,
|
|
120
|
+
* and that difference is measurable.
|
|
121
|
+
*/
|
|
122
|
+
export function safeCompare(a: string, b: string): boolean {
|
|
123
|
+
const left = Buffer.from(a, "utf8");
|
|
124
|
+
const right = Buffer.from(b, "utf8");
|
|
125
|
+
if (left.length !== right.length) return false;
|
|
126
|
+
return timingSafeEqual(left, right);
|
|
127
|
+
}
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AuthClientResponse,
|
|
9
|
+
AuthResult,
|
|
10
|
+
AuthStrategy,
|
|
11
|
+
UserPayload,
|
|
12
|
+
} from "../AuthManager.js";
|
|
8
13
|
import { nativeWarden } from "../native.js";
|
|
9
14
|
import type { TokenBlacklist } from "../TokenBlacklist.js";
|
|
10
15
|
|
|
@@ -214,6 +219,18 @@ export class JwtStrategy implements AuthStrategy {
|
|
|
214
219
|
* Returns `true` when the token was added to the blacklist, `false` when
|
|
215
220
|
* the token is already expired (revocation is unnecessary) or unparseable.
|
|
216
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* The header a test client sends to be `user` (AdonisJS
|
|
224
|
+
* `authenticateAsClient`).
|
|
225
|
+
*
|
|
226
|
+
* Signs a real token with the configured secret, so the request travels the
|
|
227
|
+
* SAME verification path as production — a test that forges its own header
|
|
228
|
+
* proves only that the forgery works.
|
|
229
|
+
*/
|
|
230
|
+
authenticateAsClient(user: UserPayload): AuthClientResponse {
|
|
231
|
+
return { headers: { authorization: `Bearer ${this.signToken(user)}` } };
|
|
232
|
+
}
|
|
233
|
+
|
|
217
234
|
async revoke(token: string): Promise<boolean> {
|
|
218
235
|
if (!this.#blacklist) {
|
|
219
236
|
throw new Error(
|
|
@@ -7,8 +7,19 @@
|
|
|
7
7
|
* @implements MISS-7
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
AuthClientResponse,
|
|
12
|
+
AuthResult,
|
|
13
|
+
AuthStrategy,
|
|
14
|
+
UserPayload,
|
|
15
|
+
} from "../AuthManager.js";
|
|
11
16
|
import { WardenError } from "../errors.js";
|
|
17
|
+
import {
|
|
18
|
+
decodeTokenValue,
|
|
19
|
+
mintRememberMeToken,
|
|
20
|
+
type RememberMeTokenDriver,
|
|
21
|
+
verifyAndRecycleRememberMeToken,
|
|
22
|
+
} from "../RememberMeToken.js";
|
|
12
23
|
|
|
13
24
|
export interface SessionStore {
|
|
14
25
|
get(key: string): unknown;
|
|
@@ -33,8 +44,27 @@ export interface SessionStore {
|
|
|
33
44
|
export interface SessionStrategyConfig {
|
|
34
45
|
sessionKey?: string;
|
|
35
46
|
findUser: (id: string | number) => Promise<UserPayload | null>;
|
|
47
|
+
/**
|
|
48
|
+
* Turn on "keep me signed in". Adonis calls the equivalent flag
|
|
49
|
+
* `useRememberMeTokens`; without a driver there is nowhere to persist the
|
|
50
|
+
* token, so passing one IS the opt-in.
|
|
51
|
+
*/
|
|
52
|
+
rememberMeTokens?: RememberMeTokenDriver;
|
|
53
|
+
/**
|
|
54
|
+
* How long a remember-me token lives. Adonis defaults to two years, and a
|
|
55
|
+
* number here is SECONDS (`durationToSeconds` semantics).
|
|
56
|
+
*/
|
|
57
|
+
rememberMeAge?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Cookie name holding the token. Adonis derives `remember_<guard>`; the
|
|
60
|
+
* default matches the `web` guard an app is most likely migrating.
|
|
61
|
+
*/
|
|
62
|
+
rememberMeCookieName?: string;
|
|
36
63
|
}
|
|
37
64
|
|
|
65
|
+
/** Two years, Adonis' `rememberMeTokensAge` default. */
|
|
66
|
+
const DEFAULT_REMEMBER_AGE_SECONDS = 63_072_000;
|
|
67
|
+
|
|
38
68
|
export class SessionStrategy implements AuthStrategy {
|
|
39
69
|
name = "session";
|
|
40
70
|
#config: SessionStrategyConfig;
|
|
@@ -45,6 +75,71 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
45
75
|
this.#sessionKey = config.sessionKey ?? "auth_user_id";
|
|
46
76
|
}
|
|
47
77
|
|
|
78
|
+
/** The cookie the remember-me token travels in. */
|
|
79
|
+
get rememberMeCookieName(): string {
|
|
80
|
+
return this.#config.rememberMeCookieName ?? "remember_web";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether "keep me signed in" is wired at all. */
|
|
84
|
+
get usesRememberMeTokens(): boolean {
|
|
85
|
+
return this.#config.rememberMeTokens !== undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#rememberMeAge(): number {
|
|
89
|
+
return this.#config.rememberMeAge ?? DEFAULT_REMEMBER_AGE_SECONDS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mint a remember-me token for `user` and return the cookie value the caller
|
|
94
|
+
* must set. `null` when the feature is not wired.
|
|
95
|
+
*/
|
|
96
|
+
async issueRememberMeToken(user: UserPayload): Promise<string | null> {
|
|
97
|
+
const driver = this.#config.rememberMeTokens;
|
|
98
|
+
if (!driver) return null;
|
|
99
|
+
const minted = mintRememberMeToken(user.id, this.#rememberMeAge());
|
|
100
|
+
await driver.create(minted.stored);
|
|
101
|
+
return minted.value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Authenticate from a remember-me cookie, and RECYCLE it: the returned
|
|
106
|
+
* `cookieValue` must replace the one the browser holds.
|
|
107
|
+
*
|
|
108
|
+
* The user is re-read through `findUser`, never trusted from the token — a
|
|
109
|
+
* token outlives the row it points at, and a deleted or disabled account
|
|
110
|
+
* must not walk back in through a cookie.
|
|
111
|
+
*/
|
|
112
|
+
async authenticateViaRememberMeToken(
|
|
113
|
+
cookieValue: unknown,
|
|
114
|
+
): Promise<{ user: UserPayload; cookieValue: string } | null> {
|
|
115
|
+
const driver = this.#config.rememberMeTokens;
|
|
116
|
+
if (!driver) return null;
|
|
117
|
+
|
|
118
|
+
const recycled = await verifyAndRecycleRememberMeToken(
|
|
119
|
+
driver,
|
|
120
|
+
cookieValue,
|
|
121
|
+
this.#rememberMeAge(),
|
|
122
|
+
);
|
|
123
|
+
if (!recycled) return null;
|
|
124
|
+
|
|
125
|
+
const user = await this.#config.findUser(recycled.userId);
|
|
126
|
+
if (!user) return null;
|
|
127
|
+
|
|
128
|
+
return { user, cookieValue: recycled.value };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Drop the token behind `cookieValue`. Called on logout so the persistent
|
|
133
|
+
* credential dies with the session — Adonis deletes the row too.
|
|
134
|
+
*/
|
|
135
|
+
async revokeRememberMeToken(cookieValue: unknown): Promise<void> {
|
|
136
|
+
const driver = this.#config.rememberMeTokens;
|
|
137
|
+
if (!driver) return;
|
|
138
|
+
const decoded = decodeTokenValue(cookieValue);
|
|
139
|
+
if (!decoded) return;
|
|
140
|
+
await driver.delete(decoded.identifier);
|
|
141
|
+
}
|
|
142
|
+
|
|
48
143
|
/** Authenticate via email/password — stores user ID in session. */
|
|
49
144
|
async authenticate(
|
|
50
145
|
_credentials: { email: string; password: string },
|
|
@@ -104,7 +199,23 @@ export class SessionStrategy implements AuthStrategy {
|
|
|
104
199
|
session.put(this.#sessionKey, user.id);
|
|
105
200
|
}
|
|
106
201
|
|
|
107
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* The session entry a test client needs to be `user` (AdonisJS
|
|
204
|
+
* `authenticateAsClient`).
|
|
205
|
+
*
|
|
206
|
+
* Returns the same key `login()` writes, so the request is authenticated by
|
|
207
|
+
* the guard's own logic rather than by the test knowing where the id lives.
|
|
208
|
+
*/
|
|
209
|
+
authenticateAsClient(user: UserPayload): AuthClientResponse {
|
|
210
|
+
return { session: { [this.#sessionKey]: user.id } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Logout — remove the user id from the session.
|
|
215
|
+
*
|
|
216
|
+
* The remember-me token is revoked separately through
|
|
217
|
+
* {@link revokeRememberMeToken}, because only the caller holds the cookie.
|
|
218
|
+
*/
|
|
108
219
|
async logout(session: SessionStore): Promise<void> {
|
|
109
220
|
session.forget(this.#sessionKey);
|
|
110
221
|
}
|