@c9up/warden 0.1.12 → 0.1.14
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 +45 -9
- package/dist/AuthManager.d.ts.map +1 -1
- package/dist/AuthManager.js +75 -23
- package/dist/AuthManager.js.map +1 -1
- package/dist/Authenticator.d.ts +128 -0
- package/dist/Authenticator.d.ts.map +1 -0
- package/dist/Authenticator.js +296 -0
- package/dist/Authenticator.js.map +1 -0
- package/dist/WardenProvider.d.ts.map +1 -1
- package/dist/WardenProvider.js +51 -23
- package/dist/WardenProvider.js.map +1 -1
- package/dist/bouncer/AbilitiesBuilder.d.ts +20 -0
- package/dist/bouncer/AbilitiesBuilder.d.ts.map +1 -0
- package/dist/bouncer/AbilitiesBuilder.js +32 -0
- package/dist/bouncer/AbilitiesBuilder.js.map +1 -0
- package/dist/bouncer/AuthorizationResponse.d.ts +18 -8
- package/dist/bouncer/AuthorizationResponse.d.ts.map +1 -1
- package/dist/bouncer/AuthorizationResponse.js +20 -8
- package/dist/bouncer/AuthorizationResponse.js.map +1 -1
- package/dist/bouncer/BasePolicy.d.ts +4 -3
- package/dist/bouncer/BasePolicy.d.ts.map +1 -1
- package/dist/bouncer/BasePolicy.js.map +1 -1
- package/dist/bouncer/Bouncer.d.ts +9 -2
- package/dist/bouncer/Bouncer.d.ts.map +1 -1
- package/dist/bouncer/Bouncer.js +59 -10
- package/dist/bouncer/Bouncer.js.map +1 -1
- package/dist/bouncer/PolicyAuthorizer.d.ts +2 -1
- package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -1
- package/dist/bouncer/PolicyAuthorizer.js +11 -3
- package/dist/bouncer/PolicyAuthorizer.js.map +1 -1
- package/dist/bouncer/evaluate.d.ts +1 -1
- package/dist/bouncer/evaluate.d.ts.map +1 -1
- package/dist/bouncer/evaluate.js +3 -2
- package/dist/bouncer/evaluate.js.map +1 -1
- package/dist/bouncer/types.d.ts +22 -1
- package/dist/bouncer/types.d.ts.map +1 -1
- package/dist/config.d.ts +34 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +15 -13
- package/dist/configure.js.map +1 -1
- package/dist/errors.d.ts +27 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +37 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/mfa/OtpProvider.d.ts.map +1 -1
- package/dist/mfa/OtpProvider.js +21 -3
- package/dist/mfa/OtpProvider.js.map +1 -1
- package/dist/mfa/WebauthnProvider.d.ts +24 -2
- package/dist/mfa/WebauthnProvider.d.ts.map +1 -1
- package/dist/mfa/WebauthnProvider.js +28 -8
- package/dist/mfa/WebauthnProvider.js.map +1 -1
- package/dist/middleware.d.ts +29 -6
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +85 -166
- package/dist/middleware.js.map +1 -1
- package/dist/standalone.d.ts.map +1 -1
- package/dist/standalone.js +21 -13
- package/dist/standalone.js.map +1 -1
- package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
- package/dist/strategies/ApiKeyStrategy.js +4 -1
- package/dist/strategies/ApiKeyStrategy.js.map +1 -1
- package/dist/strategies/SessionStrategy.js +2 -2
- 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 +2 -1
- package/src/AuthManager.ts +125 -28
- package/src/Authenticator.ts +385 -0
- package/src/WardenProvider.ts +51 -25
- package/src/bouncer/AbilitiesBuilder.ts +41 -0
- package/src/bouncer/AuthorizationResponse.ts +24 -16
- package/src/bouncer/BasePolicy.ts +4 -2
- package/src/bouncer/Bouncer.ts +77 -14
- package/src/bouncer/PolicyAuthorizer.ts +14 -4
- package/src/bouncer/evaluate.ts +4 -2
- package/src/bouncer/types.ts +24 -1
- package/src/config.ts +47 -3
- package/src/configure.ts +15 -13
- package/src/errors.ts +43 -1
- package/src/index.ts +22 -2
- package/src/mfa/OtpProvider.ts +31 -3
- package/src/mfa/WebauthnProvider.ts +58 -12
- package/src/middleware.ts +112 -207
- package/src/standalone.ts +22 -14
- package/src/strategies/ApiKeyStrategy.ts +4 -1
- package/src/strategies/SessionStrategy.ts +2 -2
package/src/WardenProvider.ts
CHANGED
|
@@ -39,35 +39,60 @@ export default class WardenProvider {
|
|
|
39
39
|
// AuthManager with `strategies: {}` and `defaultStrategy: 'jwt'`,
|
|
40
40
|
// passing the (then-permissive) constructor and erroring deep in the
|
|
41
41
|
// middleware loop.
|
|
42
|
-
|
|
42
|
+
const hasGuards = config?.guards && Object.keys(config.guards).length > 0;
|
|
43
|
+
if (!hasGuards && !config?.jwt && !config?.session && !config?.apiKey) {
|
|
43
44
|
throw new WardenError(
|
|
44
45
|
"WARDEN_NO_AUTH_CONFIG",
|
|
45
|
-
`@c9up/warden: no authentication
|
|
46
|
+
`@c9up/warden: no authentication guards configured. Set config.warden.auth.guards (AdonisJS form) or at least one of .jwt / .session / .apiKey in your reamrc.ts before registering WardenProvider.`,
|
|
46
47
|
);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
// Build the
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
50
|
+
// Build the guard table. Two accepted forms:
|
|
51
|
+
// - AdonisJS: `config.guards` (name → AuthStrategy, via *Guard() factories).
|
|
52
|
+
// - Legacy driver-keyed: `config.jwt` / `.session` / `.apiKey`.
|
|
53
|
+
// Each strategy is also exposed by its class token (so apps can resolve a
|
|
54
|
+
// specific one). The `revoke()` blacklist flows through `config.jwt.blacklist`.
|
|
53
55
|
const strategies: Record<string, AuthStrategy> = {};
|
|
54
56
|
|
|
55
|
-
if (config.
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
57
|
+
if (hasGuards && config.guards) {
|
|
58
|
+
for (const [name, strategy] of Object.entries(config.guards)) {
|
|
59
|
+
strategies[name] = strategy;
|
|
60
|
+
// Expose recognised driver instances by their class token too.
|
|
61
|
+
if (strategy instanceof JwtStrategy) {
|
|
62
|
+
this.app.container.singleton(JwtStrategy, () => strategy);
|
|
63
|
+
} else if (strategy instanceof SessionStrategy) {
|
|
64
|
+
this.app.container.singleton(SessionStrategy, () => strategy);
|
|
65
|
+
} else if (strategy instanceof ApiKeyStrategy) {
|
|
66
|
+
this.app.container.singleton(ApiKeyStrategy, () => strategy);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
if (config.jwt) {
|
|
71
|
+
const jwt = new JwtStrategy(config.jwt);
|
|
72
|
+
this.app.container.singleton(JwtStrategy, () => jwt);
|
|
73
|
+
strategies.jwt = jwt;
|
|
74
|
+
}
|
|
75
|
+
if (config.session) {
|
|
76
|
+
const session = new SessionStrategy(config.session);
|
|
77
|
+
this.app.container.singleton(SessionStrategy, () => session);
|
|
78
|
+
strategies.session = session;
|
|
79
|
+
}
|
|
80
|
+
if (config.apiKey) {
|
|
81
|
+
const apiKey = new ApiKeyStrategy(config.apiKey);
|
|
82
|
+
this.app.container.singleton(ApiKeyStrategy, () => apiKey);
|
|
83
|
+
// AdonisJS names this guard driver "access_tokens"; keep "api-key"
|
|
84
|
+
// as an accepted alias so existing `@Guard('api-key')` routes work.
|
|
85
|
+
strategies.access_tokens = apiKey;
|
|
86
|
+
strategies["api-key"] = apiKey;
|
|
87
|
+
}
|
|
69
88
|
}
|
|
70
89
|
|
|
90
|
+
// Login route (session-guard HTML redirect target) — resolved by the
|
|
91
|
+
// enforcing middleware via the "warden:loginRoute" token. Registered even
|
|
92
|
+
// when undefined so the lookup is a clean resolve, not a throw.
|
|
93
|
+
const loginRoute = config.loginRoute;
|
|
94
|
+
this.app.container.singleton("warden:loginRoute", () => loginRoute);
|
|
95
|
+
|
|
71
96
|
// Rights layer (Epic 56): one resolver singleton backs BOTH the coarse
|
|
72
97
|
// RBAC helpers (injected into AuthManager below) and — once 56.6 lands —
|
|
73
98
|
// the Bouncer construction, so a coarse question and a policy question
|
|
@@ -93,13 +118,14 @@ export default class WardenProvider {
|
|
|
93
118
|
};
|
|
94
119
|
this.app.container.singleton("bouncer:registry", () => bouncerRegistry);
|
|
95
120
|
|
|
96
|
-
// Default to the configured
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
// Default to the configured guard (AdonisJS `default`, then legacy
|
|
122
|
+
// `defaultStrategy`), else the first one registered.
|
|
123
|
+
const defaultGuard =
|
|
124
|
+
config.default ?? config.defaultStrategy ?? Object.keys(strategies)[0];
|
|
99
125
|
this.app.container.singleton(AuthManager, () => {
|
|
100
126
|
return new AuthManager({
|
|
101
|
-
|
|
102
|
-
strategies,
|
|
127
|
+
default: defaultGuard,
|
|
128
|
+
guards: strategies,
|
|
103
129
|
rights: rightsResolver,
|
|
104
130
|
});
|
|
105
131
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AbilitiesBuilder — a chainable builder that fluently assembles an object of
|
|
3
|
+
* named abilities (Adonis `AbilitiesBuilder`). Each `.define(...)` delegates to
|
|
4
|
+
* `Bouncer.ability` and stores the result on `.abilities`, returning `this` so
|
|
5
|
+
* calls chain: `new AbilitiesBuilder().define(a).define(b).abilities`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { UserPayload } from "../AuthManager.js";
|
|
9
|
+
import { Bouncer } from "./Bouncer.js";
|
|
10
|
+
import type { Ability, AbilityOptions, AuthorizerResponse } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export class AbilitiesBuilder {
|
|
13
|
+
/** The abilities accumulated so far, keyed by name. */
|
|
14
|
+
readonly abilities: Record<string, Ability<never[]>>;
|
|
15
|
+
|
|
16
|
+
constructor(abilities: Record<string, Ability<never[]>> = {}) {
|
|
17
|
+
this.abilities = abilities;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Define a named ability (Adonis `.define(name, authorizer, options?)`). The
|
|
22
|
+
* callback receives a non-null user (a guest is denied by the evaluator before
|
|
23
|
+
* it runs); pass `{ allowGuest: true }` to let the callback run for a guest.
|
|
24
|
+
*/
|
|
25
|
+
define<Args extends unknown[]>(
|
|
26
|
+
name: string,
|
|
27
|
+
authorizer: (user: UserPayload, ...args: Args) => AuthorizerResponse,
|
|
28
|
+
options?: AbilityOptions,
|
|
29
|
+
): this {
|
|
30
|
+
// Delegate the callback wrapping to `Bouncer.ability` (guest-denied form),
|
|
31
|
+
// then apply the `allowGuest` option. Store without the `__args` phantom —
|
|
32
|
+
// its concrete-args brand cannot widen to the `never[]` storage slot, while
|
|
33
|
+
// `execute` is already non-generic.
|
|
34
|
+
const built = Bouncer.ability(authorizer);
|
|
35
|
+
this.abilities[name] = {
|
|
36
|
+
allowGuest: options?.allowGuest ?? built.allowGuest,
|
|
37
|
+
execute: built.execute,
|
|
38
|
+
};
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -3,27 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Layer 2 (EVALUATION) of Warden's unified authorization (Epic 56). Faithful
|
|
5
5
|
* to AdonisJS Bouncer's `AuthorizationResponse`: instances are produced only by
|
|
6
|
-
* the `allow()` / `deny()` static constructors (the constructor is private)
|
|
7
|
-
* denial
|
|
8
|
-
*
|
|
6
|
+
* the `allow()` / `deny()` static constructors (the constructor is private). A
|
|
7
|
+
* denial carries no `status` unless one is passed — the HTTP 403 default is
|
|
8
|
+
* applied only at the throw/HTTP boundary (Adonis parity) — and a `translation`
|
|
9
|
+
* field carries the i18n binding set via {@link t}.
|
|
9
10
|
*/
|
|
10
11
|
export class AuthorizationResponse {
|
|
11
12
|
readonly authorized: boolean;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
|
|
13
|
+
message?: string;
|
|
14
|
+
status?: number;
|
|
15
|
+
/** i18n binding set via {@link t} (Adonis `AuthorizationResponse.t`). */
|
|
16
|
+
translation?: { identifier: string; data?: Record<string, unknown> };
|
|
16
17
|
|
|
17
|
-
private constructor(
|
|
18
|
-
authorized: boolean,
|
|
19
|
-
message?: string,
|
|
20
|
-
status?: number,
|
|
21
|
-
translation?: { identifier: string; data?: Record<string, unknown> },
|
|
22
|
-
) {
|
|
18
|
+
private constructor(authorized: boolean, message?: string, status?: number) {
|
|
23
19
|
this.authorized = authorized;
|
|
24
20
|
this.message = message;
|
|
25
21
|
this.status = status;
|
|
26
|
-
this.translation = translation;
|
|
27
22
|
}
|
|
28
23
|
|
|
29
24
|
/** Authorized response (no status). */
|
|
@@ -31,8 +26,21 @@ export class AuthorizationResponse {
|
|
|
31
26
|
return new AuthorizationResponse(true);
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Denied response. `status` is left `undefined` unless passed — the 403
|
|
31
|
+
* default is applied only when the denial is thrown / mapped to HTTP (Adonis
|
|
32
|
+
* parity).
|
|
33
|
+
*/
|
|
34
|
+
static deny(message?: string, status?: number): AuthorizationResponse {
|
|
36
35
|
return new AuthorizationResponse(false, message, status);
|
|
37
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Set the i18n translation binding and return `this` for chaining (Adonis
|
|
40
|
+
* `AuthorizationResponse.t`), e.g. `AuthorizationResponse.deny().t('errors.forbidden')`.
|
|
41
|
+
*/
|
|
42
|
+
t(identifier: string, data?: Record<string, unknown>): this {
|
|
43
|
+
this.translation = { identifier, data };
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
38
46
|
}
|
|
@@ -27,13 +27,15 @@ export abstract class BasePolicy {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Runs after the action (or after a `before` short-circuit), receiving the
|
|
30
|
-
* resolved response
|
|
31
|
-
*
|
|
30
|
+
* resolved response and the same resource args passed to the action (Adonis
|
|
31
|
+
* `after(user, action, result, ...args)`). A non-`undefined` return overrides
|
|
32
|
+
* the response; `undefined` keeps it.
|
|
32
33
|
*/
|
|
33
34
|
after?(
|
|
34
35
|
user: UserPayload | null,
|
|
35
36
|
action: string,
|
|
36
37
|
result: AuthorizationResponse,
|
|
38
|
+
...args: unknown[]
|
|
37
39
|
): HookResponse;
|
|
38
40
|
|
|
39
41
|
/**
|
package/src/bouncer/Bouncer.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { UserPayload } from "../AuthManager.js";
|
|
|
15
15
|
import { WardenError } from "../errors.js";
|
|
16
16
|
import type { RightsResolver } from "../rights/RightsResolver.js";
|
|
17
17
|
import type { EffectivePermissions, Scope } from "../rights/types.js";
|
|
18
|
+
import { AbilitiesBuilder } from "./AbilitiesBuilder.js";
|
|
18
19
|
import { AuthorizationResponse } from "./AuthorizationResponse.js";
|
|
19
20
|
import type { BasePolicy } from "./BasePolicy.js";
|
|
20
21
|
import { evaluate, isAction, throwAuthorizationFailure } from "./evaluate.js";
|
|
@@ -25,28 +26,63 @@ import type {
|
|
|
25
26
|
AbilityOptions,
|
|
26
27
|
AuthorizerResponse,
|
|
27
28
|
BouncerContext,
|
|
29
|
+
BouncerEmitter,
|
|
30
|
+
PolicyContainerResolver,
|
|
28
31
|
} from "./types.js";
|
|
29
32
|
|
|
30
33
|
export class Bouncer {
|
|
31
|
-
readonly #
|
|
34
|
+
readonly #userOrResolver: UserPayload | (() => UserPayload | null) | null;
|
|
35
|
+
/** Lazily-resolved user cache (`undefined` until `#getUser` runs). */
|
|
36
|
+
#user: UserPayload | null | undefined;
|
|
32
37
|
readonly #abilities: Record<string, Ability<never[]>>;
|
|
33
38
|
readonly #policies: Record<string, new () => BasePolicy>;
|
|
34
39
|
readonly #scope: Scope;
|
|
35
40
|
readonly #resolver: RightsResolver | undefined;
|
|
41
|
+
readonly #containerResolver: PolicyContainerResolver | undefined;
|
|
42
|
+
readonly #emitter: BouncerEmitter | undefined;
|
|
36
43
|
/** Memoized resolution — a Bouncer is fixed per `(user, scope)`, so resolve once (D3). */
|
|
37
44
|
#resolved: Promise<EffectivePermissions> | undefined;
|
|
38
45
|
|
|
39
46
|
constructor(
|
|
40
|
-
user: UserPayload | null,
|
|
47
|
+
user: UserPayload | (() => UserPayload | null) | null,
|
|
41
48
|
abilities?: Record<string, Ability<never[]>>,
|
|
42
49
|
policies?: Record<string, new () => BasePolicy>,
|
|
43
50
|
context?: BouncerContext,
|
|
44
51
|
) {
|
|
45
|
-
this.#
|
|
52
|
+
this.#userOrResolver = user;
|
|
46
53
|
this.#abilities = abilities ?? {};
|
|
47
54
|
this.#policies = policies ?? {};
|
|
48
55
|
this.#scope = context?.scope ?? "global";
|
|
49
56
|
this.#resolver = context?.resolver;
|
|
57
|
+
this.#containerResolver = context?.containerResolver;
|
|
58
|
+
this.#emitter = context?.emitter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the user, lazily invoking a resolver callback once and memoizing
|
|
63
|
+
* the result (Adonis `#getUser`). A plain `UserPayload | null` passes through.
|
|
64
|
+
*/
|
|
65
|
+
#getUser(): UserPayload | null {
|
|
66
|
+
if (this.#user === undefined) {
|
|
67
|
+
this.#user =
|
|
68
|
+
typeof this.#userOrResolver === "function"
|
|
69
|
+
? this.#userOrResolver()
|
|
70
|
+
: this.#userOrResolver;
|
|
71
|
+
}
|
|
72
|
+
return this.#user;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Define an ability and open a chainable {@link AbilitiesBuilder} (Adonis
|
|
77
|
+
* `Bouncer.define`). Read `.abilities` off the returned builder to pass into a
|
|
78
|
+
* `new Bouncer(user, abilities)`.
|
|
79
|
+
*/
|
|
80
|
+
static define<Args extends unknown[]>(
|
|
81
|
+
name: string,
|
|
82
|
+
authorizer: (user: UserPayload, ...args: Args) => AuthorizerResponse,
|
|
83
|
+
options?: AbilityOptions,
|
|
84
|
+
): AbilitiesBuilder {
|
|
85
|
+
return new AbilitiesBuilder().define(name, authorizer, options);
|
|
50
86
|
}
|
|
51
87
|
|
|
52
88
|
/** The active resolution scope (default `"global"`). */
|
|
@@ -62,9 +98,10 @@ export class Bouncer {
|
|
|
62
98
|
*/
|
|
63
99
|
#resolvePermissions(): Promise<EffectivePermissions> {
|
|
64
100
|
if (this.#resolved === undefined) {
|
|
101
|
+
const user = this.#getUser();
|
|
65
102
|
this.#resolved =
|
|
66
|
-
this.#resolver !== undefined &&
|
|
67
|
-
? this.#resolver.resolve(
|
|
103
|
+
this.#resolver !== undefined && user !== null
|
|
104
|
+
? this.#resolver.resolve(user, this.#scope)
|
|
68
105
|
: Promise.resolve(emptyPermissions(this.#scope));
|
|
69
106
|
}
|
|
70
107
|
return this.#resolved;
|
|
@@ -120,19 +157,42 @@ export class Bouncer {
|
|
|
120
157
|
return AuthorizationResponse.deny(message, status);
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
/** Open a policy for checks (D8 — fresh
|
|
160
|
+
/** Open a policy for checks (D8 — fresh policy instance per check). */
|
|
124
161
|
with(policy: (new () => BasePolicy) | string): PolicyAuthorizer {
|
|
125
162
|
const factory =
|
|
126
163
|
typeof policy === "string"
|
|
127
164
|
? this.#resolvePolicyClass(policy)
|
|
128
|
-
: () =>
|
|
165
|
+
: () => this.#construct(policy);
|
|
129
166
|
// The PolicyAuthorizer inherits the Bouncer's scope + the shared memoized
|
|
130
167
|
// resolve so every policy check sees the active `(user, scope)` (AC1/AC3).
|
|
131
|
-
return new PolicyAuthorizer(
|
|
132
|
-
this.#
|
|
168
|
+
return new PolicyAuthorizer(
|
|
169
|
+
this.#getUser(),
|
|
170
|
+
factory,
|
|
171
|
+
this.#scope,
|
|
172
|
+
() => this.#resolvePermissions(),
|
|
173
|
+
this.#emitter,
|
|
133
174
|
);
|
|
134
175
|
}
|
|
135
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Construct a policy instance via the container resolver when present (Adonis
|
|
179
|
+
* DI parity), else a plain `new Policy()` (D8 — a fresh instance per check).
|
|
180
|
+
*/
|
|
181
|
+
#construct(ctor: new () => BasePolicy): Promise<BasePolicy> {
|
|
182
|
+
return this.#containerResolver !== undefined
|
|
183
|
+
? this.#containerResolver.make(ctor)
|
|
184
|
+
: Promise.resolve(new ctor());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Emit `authorization:finished` when an emitter is wired (no-op otherwise). */
|
|
188
|
+
#emit(action: string, response: AuthorizationResponse): void {
|
|
189
|
+
this.#emitter?.emit("authorization:finished", {
|
|
190
|
+
user: this.#getUser(),
|
|
191
|
+
action,
|
|
192
|
+
response,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
136
196
|
/** Run an ability check and resolve to the full response. */
|
|
137
197
|
execute<Args extends unknown[]>(
|
|
138
198
|
ability: Ability<Args>,
|
|
@@ -194,13 +254,16 @@ export class Bouncer {
|
|
|
194
254
|
): Promise<AuthorizationResponse> {
|
|
195
255
|
const resolved =
|
|
196
256
|
typeof ability === "string" ? this.#resolveAbility(ability) : ability;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
257
|
+
const action = typeof ability === "string" ? ability : "(ability)";
|
|
258
|
+
const response = await evaluate({
|
|
259
|
+
user: this.#getUser(),
|
|
260
|
+
action,
|
|
200
261
|
allowGuest: resolved.allowGuest,
|
|
201
262
|
run: (user) => resolved.execute(user, ...args),
|
|
202
263
|
args,
|
|
203
264
|
});
|
|
265
|
+
this.#emit(action, response);
|
|
266
|
+
return response;
|
|
204
267
|
}
|
|
205
268
|
|
|
206
269
|
#resolveAbility(name: string): Ability<never[]> {
|
|
@@ -215,7 +278,7 @@ export class Bouncer {
|
|
|
215
278
|
return found;
|
|
216
279
|
}
|
|
217
280
|
|
|
218
|
-
#resolvePolicyClass(name: string): () => BasePolicy {
|
|
281
|
+
#resolvePolicyClass(name: string): () => Promise<BasePolicy> {
|
|
219
282
|
// Lazy: the lookup runs when a verb constructs the policy, so an unknown
|
|
220
283
|
// name surfaces as a promise rejection (like every other verb error),
|
|
221
284
|
// not a synchronous throw at `with()` time.
|
|
@@ -230,7 +293,7 @@ export class Bouncer {
|
|
|
230
293
|
},
|
|
231
294
|
);
|
|
232
295
|
}
|
|
233
|
-
return
|
|
296
|
+
return this.#construct(ctor);
|
|
234
297
|
};
|
|
235
298
|
}
|
|
236
299
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
throwAuthorizationFailure,
|
|
20
20
|
} from "./evaluate.js";
|
|
21
21
|
import { emptyPermissions, setPolicyContext } from "./policyContext.js";
|
|
22
|
+
import type { BouncerEmitter } from "./types.js";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Resolve a named action method declared anywhere on the policy's own subclass
|
|
@@ -65,21 +66,24 @@ function resolveActionMethod(policy: BasePolicy, action: string): Action {
|
|
|
65
66
|
|
|
66
67
|
export class PolicyAuthorizer {
|
|
67
68
|
readonly #user: UserPayload | null;
|
|
68
|
-
readonly #factory: () => BasePolicy
|
|
69
|
+
readonly #factory: () => Promise<BasePolicy>;
|
|
69
70
|
readonly #scope: Scope;
|
|
70
71
|
readonly #resolvePermissions: () => Promise<EffectivePermissions>;
|
|
72
|
+
readonly #emitter: BouncerEmitter | undefined;
|
|
71
73
|
|
|
72
74
|
constructor(
|
|
73
75
|
user: UserPayload | null,
|
|
74
|
-
factory: () => BasePolicy
|
|
76
|
+
factory: () => Promise<BasePolicy>,
|
|
75
77
|
scope: Scope = "global",
|
|
76
78
|
resolvePermissions: () => Promise<EffectivePermissions> = () =>
|
|
77
79
|
Promise.resolve(emptyPermissions(scope)),
|
|
80
|
+
emitter?: BouncerEmitter,
|
|
78
81
|
) {
|
|
79
82
|
this.#user = user;
|
|
80
83
|
this.#factory = factory;
|
|
81
84
|
this.#scope = scope;
|
|
82
85
|
this.#resolvePermissions = resolvePermissions;
|
|
86
|
+
this.#emitter = emitter;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/** Run a check and resolve to the full response (D8 — fresh policy per check). */
|
|
@@ -87,14 +91,14 @@ export class PolicyAuthorizer {
|
|
|
87
91
|
action: string,
|
|
88
92
|
...args: unknown[]
|
|
89
93
|
): Promise<AuthorizationResponse> {
|
|
90
|
-
const policy = this.#factory();
|
|
94
|
+
const policy = await this.#factory();
|
|
91
95
|
// Attach the active scope + resolved permissions BEFORE dispatch so the
|
|
92
96
|
// before/method/after pipeline all read `this.scope` / `this.permissions`.
|
|
93
97
|
const permissions = await this.#resolvePermissions();
|
|
94
98
|
setPolicyContext(policy, { scope: this.#scope, permissions });
|
|
95
99
|
const method = resolveActionMethod(policy, action);
|
|
96
100
|
const options = getActionMetadata(policy, action);
|
|
97
|
-
|
|
101
|
+
const response = await evaluate({
|
|
98
102
|
user: this.#user,
|
|
99
103
|
action,
|
|
100
104
|
allowGuest: options.allowGuest ?? false,
|
|
@@ -103,6 +107,12 @@ export class PolicyAuthorizer {
|
|
|
103
107
|
before: policy.before?.bind(policy),
|
|
104
108
|
after: policy.after?.bind(policy),
|
|
105
109
|
});
|
|
110
|
+
this.#emitter?.emit("authorization:finished", {
|
|
111
|
+
user: this.#user,
|
|
112
|
+
action,
|
|
113
|
+
response,
|
|
114
|
+
});
|
|
115
|
+
return response;
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
/** True iff the action is authorized. Never throws on denial. */
|
package/src/bouncer/evaluate.ts
CHANGED
|
@@ -69,6 +69,7 @@ export async function evaluate(params: {
|
|
|
69
69
|
user: UserPayload | null,
|
|
70
70
|
action: string,
|
|
71
71
|
result: AuthorizationResponse,
|
|
72
|
+
...args: unknown[]
|
|
72
73
|
) => HookResponse;
|
|
73
74
|
}): Promise<AuthorizationResponse> {
|
|
74
75
|
const { user, action, allowGuest, run, args, before, after } = params;
|
|
@@ -92,9 +93,10 @@ export async function evaluate(params: {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
// (4) after — non-undefined overrides.
|
|
96
|
+
// (4) after — non-undefined overrides. Receives the resource args (Adonis
|
|
97
|
+
// `policy.after(user, action, result, ...args)`).
|
|
96
98
|
if (after) {
|
|
97
|
-
const override = await after(user, action, response);
|
|
99
|
+
const override = await after(user, action, response, ...args);
|
|
98
100
|
if (override !== undefined) {
|
|
99
101
|
response = normalizeResponse(override);
|
|
100
102
|
}
|
package/src/bouncer/types.ts
CHANGED
|
@@ -11,9 +11,28 @@ import type { RightsResolver } from "../rights/RightsResolver.js";
|
|
|
11
11
|
import type { Scope } from "../rights/types.js";
|
|
12
12
|
import type { AuthorizationResponse } from "./AuthorizationResponse.js";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Agnostic IoC-container resolver used to construct policy instances (Adonis
|
|
16
|
+
* `PolicyAuthorizer`'s `#containerResolver`). Same shape as `ctx.containerResolver`
|
|
17
|
+
* so a Ream request context is passed through directly — warden never imports the
|
|
18
|
+
* container. Absent ⇒ policies are built with a plain `new Policy()`.
|
|
19
|
+
*/
|
|
20
|
+
export interface PolicyContainerResolver {
|
|
21
|
+
make<T>(ctor: new (...args: never[]) => T): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Agnostic event sink (Adonis `Bouncer.emitter`). When present, an
|
|
26
|
+
* `authorization:finished` event fires after every ability/policy evaluation with
|
|
27
|
+
* `{ user, action, response }`. No-op when absent.
|
|
28
|
+
*/
|
|
29
|
+
export interface BouncerEmitter {
|
|
30
|
+
emit(event: string, payload: unknown): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
/**
|
|
15
34
|
* Optional 4th `Bouncer` ctor argument (56.3, D1) — the scope dimension + the
|
|
16
|
-
* Layer-1 resolver.
|
|
35
|
+
* Layer-1 resolver. All optional; omitting it ⇒ the implicit `global` scope
|
|
17
36
|
* with no resolver (single-tenant zero-config, D7). Additive: every 56.2 call
|
|
18
37
|
* site keeps compiling.
|
|
19
38
|
*/
|
|
@@ -22,6 +41,10 @@ export interface BouncerContext {
|
|
|
22
41
|
readonly scope?: Scope;
|
|
23
42
|
/** Layer-1 resolver consulted for this Bouncer's `(user, scope)`. */
|
|
24
43
|
readonly resolver?: RightsResolver;
|
|
44
|
+
/** IoC-container resolver used to construct policies (Adonis DI parity). */
|
|
45
|
+
readonly containerResolver?: PolicyContainerResolver;
|
|
46
|
+
/** Event sink for `authorization:finished` (Adonis `Bouncer.emitter`). */
|
|
47
|
+
readonly emitter?: BouncerEmitter;
|
|
25
48
|
}
|
|
26
49
|
|
|
27
50
|
/** A predicate's return — bool sugar or an explicit response, sync or async (D7). */
|
package/src/config.ts
CHANGED
|
@@ -15,13 +15,16 @@
|
|
|
15
15
|
* })
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { UserPayload } from "./AuthManager.js";
|
|
18
|
+
import type { AuthStrategy, UserPayload } from "./AuthManager.js";
|
|
19
19
|
import type { BasePolicy } from "./bouncer/BasePolicy.js";
|
|
20
20
|
import type { Ability } from "./bouncer/types.js";
|
|
21
21
|
import type { MfaManager } from "./mfa/MfaManager.js";
|
|
22
22
|
import type { RightsStore, Scope } from "./rights/types.js";
|
|
23
23
|
import type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
|
|
24
|
+
import { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
|
|
25
|
+
import { JwtStrategy } from "./strategies/JwtStrategy.js";
|
|
24
26
|
import type { SessionStrategyConfig } from "./strategies/SessionStrategy.js";
|
|
27
|
+
import { SessionStrategy } from "./strategies/SessionStrategy.js";
|
|
25
28
|
import type { TokenBlacklist } from "./TokenBlacklist.js";
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -50,10 +53,51 @@ export interface JwtConfig {
|
|
|
50
53
|
blacklist?: TokenBlacklist;
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A guard entry in the AdonisJS-style config — an {@link AuthStrategy} instance,
|
|
58
|
+
* built via {@link jwtGuard}/{@link sessionGuard}/{@link apiKeyGuard}. Named a
|
|
59
|
+
* "factory" for AdonisJS symmetry (`sessionGuard({...})`), though Warden guards
|
|
60
|
+
* are shared per-app instances (the per-request state lives on the Authenticator).
|
|
61
|
+
*/
|
|
62
|
+
export type GuardFactory = AuthStrategy;
|
|
63
|
+
|
|
64
|
+
/** Build a JWT guard from its config (AdonisJS `jwtGuard()` shape). */
|
|
65
|
+
export function jwtGuard(config: JwtConfig): GuardFactory {
|
|
66
|
+
return new JwtStrategy(config);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a session guard from its config (AdonisJS `sessionGuard()` shape). */
|
|
70
|
+
export function sessionGuard(config: SessionStrategyConfig): GuardFactory {
|
|
71
|
+
return new SessionStrategy(config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build an API-key / access-tokens guard from its config. */
|
|
75
|
+
export function apiKeyGuard(config: ApiKeyConfig): GuardFactory {
|
|
76
|
+
return new ApiKeyStrategy(config);
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
export interface WardenConfig {
|
|
54
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* AdonisJS-style default guard NAME — the key in {@link WardenConfig.guards}
|
|
82
|
+
* used when a route/call names none. Preferred over {@link
|
|
83
|
+
* WardenConfig.defaultStrategy}.
|
|
84
|
+
*/
|
|
85
|
+
default?: string;
|
|
86
|
+
/**
|
|
87
|
+
* AdonisJS-style named guard map, e.g.
|
|
88
|
+
* `{ web: sessionGuard({...}), api: jwtGuard({...}) }`. Guards are named by
|
|
89
|
+
* YOU (multiple instances of the same driver are allowed). When supplied,
|
|
90
|
+
* this takes precedence over the driver-keyed `jwt`/`session`/`apiKey` fields.
|
|
91
|
+
*/
|
|
92
|
+
guards?: Record<string, GuardFactory>;
|
|
93
|
+
/**
|
|
94
|
+
* Login route an HTML client is redirected to when a session-guarded request
|
|
95
|
+
* is unauthenticated (AdonisJS session renderer parity). Absent ⇒ 401 JSON.
|
|
96
|
+
*/
|
|
97
|
+
loginRoute?: string;
|
|
98
|
+
/** Legacy default auth strategy name (default: 'jwt'). Prefer {@link WardenConfig.default}. */
|
|
55
99
|
defaultStrategy?: string;
|
|
56
|
-
/** JWT strategy configuration. */
|
|
100
|
+
/** JWT strategy configuration (legacy driver-keyed form). */
|
|
57
101
|
jwt?: JwtConfig;
|
|
58
102
|
/**
|
|
59
103
|
* Session strategy configuration. Supply `findUser` so `@Guard('session')`
|
package/src/configure.ts
CHANGED
|
@@ -19,21 +19,23 @@ export async function configure(codemods: Codemods): Promise<void> {
|
|
|
19
19
|
});
|
|
20
20
|
await codemods.writeFile(
|
|
21
21
|
"config/auth.ts",
|
|
22
|
-
`import { defineConfig } from '@c9up/warden'
|
|
22
|
+
`import { defineConfig, jwtGuard } from '@c9up/warden'
|
|
23
23
|
|
|
24
24
|
export default defineConfig({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
default: 'jwt',
|
|
26
|
+
guards: {
|
|
27
|
+
jwt: jwtGuard({
|
|
28
|
+
secret: process.env.JWT_SECRET ?? '',
|
|
29
|
+
expiresInSeconds: Number(process.env.JWT_EXPIRY ?? '3600'),
|
|
30
|
+
// TODO: wire these to your user model (e.g. via your ORM).
|
|
31
|
+
// The JWT guard needs both to issue and verify tokens.
|
|
32
|
+
findUser: async (_id) => {
|
|
33
|
+
throw new Error('TODO: implement findUser(id) for the JWT guard in config/auth.ts')
|
|
34
|
+
},
|
|
35
|
+
verifyCredentials: async (_email, _password) => {
|
|
36
|
+
throw new Error('TODO: implement verifyCredentials(email, password) in config/auth.ts')
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
37
39
|
},
|
|
38
40
|
})
|
|
39
41
|
`,
|