ldap-rest 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/auth/base.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/authz/composition.js +1 -0
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapCodes.js +1 -0
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/storage/store.js +1 -0
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/authzDynamic.js +1 -1
- package/dist/plugins/auth/authzPerBranch.js +1 -1
- package/dist/plugins/auth/authzPerRoute.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/auth/hmac.js +1 -1
- package/dist/plugins/auth/llng.js +1 -1
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/auth/token.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/bcl.js +1 -0
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/priority.json.js +1 -1
- package/dist/plugins/scim/baseResolver.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/scim/groups.js +1 -1
- package/dist/plugins/scim/list.js +1 -1
- package/dist/plugins/scim/users.js +1 -1
- package/dist/plugins/storage/file.js +1 -0
- package/dist/plugins/storage/ldap.js +1 -0
- package/dist/plugins/storage.js +1 -0
- package/dist/src/abstract/ldapFlat.d.ts +11 -3
- package/dist/src/abstract/plugin.d.ts +20 -0
- package/dist/src/bin/index.d.ts +50 -8
- package/dist/src/config/args.d.ts +36 -0
- package/dist/src/hooks.d.ts +55 -0
- package/dist/src/lib/auth/base.d.ts +175 -9
- package/dist/src/lib/authz/base.d.ts +133 -4
- package/dist/src/lib/authz/composition.d.ts +145 -0
- package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
- package/dist/src/lib/ldapActions.d.ts +30 -0
- package/dist/src/lib/ldapCodes.d.ts +28 -0
- package/dist/src/lib/storage/store.d.ts +84 -0
- package/dist/src/lib/utils.d.ts +19 -0
- package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
- package/dist/src/plugins/auth/authzPerBranch.d.ts +11 -0
- package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
- package/dist/src/plugins/auth/authzScope.d.ts +44 -3
- package/dist/src/plugins/auth/hmac.d.ts +2 -0
- package/dist/src/plugins/auth/llng.d.ts +39 -0
- package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
- package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
- package/dist/src/plugins/auth/token.d.ts +2 -0
- package/dist/src/plugins/auth/totp.d.ts +2 -0
- package/dist/src/plugins/bcl.d.ts +65 -0
- package/dist/src/plugins/ldap/organizations.d.ts +90 -1
- package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
- package/dist/src/plugins/scim/errors.d.ts +2 -10
- package/dist/src/plugins/storage/file.d.ts +19 -0
- package/dist/src/plugins/storage/ldap.d.ts +17 -0
- package/dist/src/plugins/storage.d.ts +44 -0
- package/docs/plugin-development/README.md +12 -1
- package/docs/plugin-development/dependencies.md +40 -15
- package/docs/usage/configuration.md +89 -13
- package/docs/usage/plugins/README.md +17 -12
- package/docs/usage/plugins/auth/README.md +129 -0
- package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
- package/docs/usage/plugins/auth/authz-linid1.md +32 -0
- package/docs/usage/plugins/auth/authz-per-branch.md +8 -0
- package/docs/usage/plugins/auth/authz-per-route.md +6 -0
- package/docs/usage/plugins/auth/authz-scope.md +69 -16
- package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
- package/docs/usage/plugins/auth/llng.md +29 -0
- package/docs/usage/plugins/auth/oidc.md +52 -0
- package/docs/usage/plugins/ldap/organizations.md +37 -0
- package/docs/usage/plugins/utilities/storage.md +94 -0
- package/docs/usage/upgrading.md +289 -0
- package/package.json +17 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module lib/storage/store
|
|
3
|
+
*
|
|
4
|
+
* A small keyed store, and what every backend of it shares.
|
|
5
|
+
*
|
|
6
|
+
* What a consumer gets is deliberately thin: a key, a value, a deadline, and
|
|
7
|
+
* the promise that the value stops being returned once that deadline passes.
|
|
8
|
+
* Nothing here knows why a record exists — Back-Channel Logout tombstones
|
|
9
|
+
* were the first use, a refresh-token lock and a shared counter are the ones
|
|
10
|
+
* in sight, and none of them is this layer's business.
|
|
11
|
+
*
|
|
12
|
+
* Two rules the backends do not get to reinterpret:
|
|
13
|
+
*
|
|
14
|
+
* - **Expiry is enforced on read.** Not every backend can expire a record by
|
|
15
|
+
* itself, and the one that can (Valkey's TTL) must still agree with the
|
|
16
|
+
* others about when a record died. A sweeper running late is then a
|
|
17
|
+
* storage cost, never a wrong answer.
|
|
18
|
+
* - **The deadline belongs to the consumer.** How long a record lives is the
|
|
19
|
+
* policy of whoever wrote it; the store is told an instant and respects
|
|
20
|
+
* it. A backend deciding for itself is how four of them come to disagree
|
|
21
|
+
* about the same question.
|
|
22
|
+
*
|
|
23
|
+
* Keys are namespaced so two consumers cannot collide in one LDAP branch or
|
|
24
|
+
* one directory.
|
|
25
|
+
*
|
|
26
|
+
* @group Libraries
|
|
27
|
+
*/
|
|
28
|
+
import type winston from 'winston';
|
|
29
|
+
/** What a backend keeps: a value, and when it stops counting. */
|
|
30
|
+
export interface StoredRecord {
|
|
31
|
+
value: string;
|
|
32
|
+
deadline: number;
|
|
33
|
+
}
|
|
34
|
+
export default abstract class Store {
|
|
35
|
+
/** Name of the backend, for the log lines it writes. */
|
|
36
|
+
abstract name: string;
|
|
37
|
+
protected logger: winston.Logger;
|
|
38
|
+
private sweeper?;
|
|
39
|
+
constructor(logger: winston.Logger);
|
|
40
|
+
/** Read one record, or null when nothing is kept under this key. */
|
|
41
|
+
protected abstract load(key: string): Promise<StoredRecord | null>;
|
|
42
|
+
/** Write one record, replacing whatever was there. */
|
|
43
|
+
protected abstract save(key: string, record: StoredRecord): Promise<void>;
|
|
44
|
+
/** Drop one key. A key that is not there is not an error. */
|
|
45
|
+
protected abstract drop(key: string): Promise<void>;
|
|
46
|
+
/** Drop every expired record. Returns how many went. */
|
|
47
|
+
abstract sweep(): Promise<number>;
|
|
48
|
+
/**
|
|
49
|
+
* The key a backend actually sees, namespaced so two consumers cannot
|
|
50
|
+
* collide.
|
|
51
|
+
*
|
|
52
|
+
* Joined on NUL, not on a colon: `('bcl', 'x:y')` and `('bcl:x', 'y')`
|
|
53
|
+
* would otherwise be the same string, and one record would answer for
|
|
54
|
+
* both. A consumer picks its namespace in code, so a colon in one is
|
|
55
|
+
* unlikely — but for a store about to hold session state, holding by
|
|
56
|
+
* construction beats holding by convention. `lib/ldapActions` moved its own
|
|
57
|
+
* cache keys to NUL for the same reason.
|
|
58
|
+
*/
|
|
59
|
+
protected static scoped(namespace: string, key: string): string;
|
|
60
|
+
/** The same pair, for a human reading a record. Never used to look one up. */
|
|
61
|
+
protected static readable(scoped: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* The value kept under this key, or null.
|
|
64
|
+
*
|
|
65
|
+
* A record past its deadline is not one: it is dropped and answered as
|
|
66
|
+
* absent, whatever the sweeper has or has not done. Consumers rely on this
|
|
67
|
+
* — a Back-Channel Logout mark that outlives its session would keep out a
|
|
68
|
+
* person who has since logged in again.
|
|
69
|
+
*/
|
|
70
|
+
get(namespace: string, key: string): Promise<string | null>;
|
|
71
|
+
/** Keep `value` under this key until `deadline`, an epoch in milliseconds. */
|
|
72
|
+
set(namespace: string, key: string, value: string, deadline: number): Promise<void>;
|
|
73
|
+
/** Forget this key. */
|
|
74
|
+
delete(namespace: string, key: string): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Run the sweeper every `seconds`, and once now.
|
|
77
|
+
*
|
|
78
|
+
* Unreferenced, so a server with nothing else to do still exits, and a
|
|
79
|
+
* sweep that fails warns rather than taking the server with it: reads
|
|
80
|
+
* enforce expiry anyway, so the cost of a failed sweep is storage.
|
|
81
|
+
*/
|
|
82
|
+
startSweeping(seconds: number): void;
|
|
83
|
+
stopSweeping(): void;
|
|
84
|
+
}
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -22,6 +22,25 @@ export declare function getCompiledRegex(pattern: string, flags?: string): RegEx
|
|
|
22
22
|
*/
|
|
23
23
|
export declare function escapeRegex(str: string): string;
|
|
24
24
|
export declare const launchHooks: (hooks: Function[] | undefined, ...args: unknown[]) => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Record the plugin a hook function belongs to.
|
|
27
|
+
*
|
|
28
|
+
* @param fn the registered function
|
|
29
|
+
* @param plugin the plugin's instance name
|
|
30
|
+
* @param hook the hook it is registered under
|
|
31
|
+
*/
|
|
32
|
+
export declare const recordHookOwner: (fn: Function, plugin: string, hook: string) => void;
|
|
33
|
+
/**
|
|
34
|
+
* The plugin a hook function belongs to, and the hook it is registered
|
|
35
|
+
* under, when `registerPlugin` registered it.
|
|
36
|
+
*
|
|
37
|
+
* @param fn a hook function
|
|
38
|
+
* @returns its plugin's instance name and hook name, or undefined
|
|
39
|
+
*/
|
|
40
|
+
export declare const hookOwner: (fn: Function) => {
|
|
41
|
+
plugin: string;
|
|
42
|
+
hook: string;
|
|
43
|
+
} | undefined;
|
|
25
44
|
export declare const launchHooksChained: <T>(hooks: Function[] | undefined, args: T) => Promise<T>;
|
|
26
45
|
export declare const transformSchemas: (schemas: string | Buffer, config: Config) => string;
|
|
27
46
|
/**
|
|
@@ -92,6 +92,82 @@ export default class AuthzDynamic extends AuthBase {
|
|
|
92
92
|
* during an outage.
|
|
93
93
|
*/
|
|
94
94
|
private ensureFresh;
|
|
95
|
+
/**
|
|
96
|
+
* Whether this request is allowed past the token ACLs without a token.
|
|
97
|
+
*
|
|
98
|
+
* The plugin used to step aside as soon as anything else had set
|
|
99
|
+
* `req.user`, which is not a decision anyone wrote down: with
|
|
100
|
+
* `core/auth/token` and this plugin both unscoped, the dispatcher runs the
|
|
101
|
+
* token plugin first, it sets `req.user`, and every LDAP operation of that
|
|
102
|
+
* request was then checked against no ACL at all — a branch-unrestricted
|
|
103
|
+
* administrator. Registered the other way round, the same configuration
|
|
104
|
+
* demanded both credentials. An ordering accident decided which.
|
|
105
|
+
*
|
|
106
|
+
* The comment that justified it named `trustedProxy + authzDynamic`, a
|
|
107
|
+
* composition that never worked: `trustedProxy` sets `req.trustedProxy`
|
|
108
|
+
* and `req.proxyAuthUser`, never `req.user`, so that pair never took this
|
|
109
|
+
* path — while every authenticator that does set it, did.
|
|
110
|
+
*
|
|
111
|
+
* So the bypass is named now. `--authz-dynamic-bypass` lists the
|
|
112
|
+
* identities that may skip the ACLs, plus `trusted-proxy` for the
|
|
113
|
+
* composition the comment meant and `any-authenticated` for the old
|
|
114
|
+
* behaviour, which a deployment relying on it can ask for in one line.
|
|
115
|
+
*
|
|
116
|
+
* @param req request being authenticated
|
|
117
|
+
* @returns what allowed it past, or undefined when nothing does
|
|
118
|
+
*/
|
|
119
|
+
private bypassReason;
|
|
120
|
+
/**
|
|
121
|
+
* Say, once and at startup, when a configuration is ambiguous.
|
|
122
|
+
*
|
|
123
|
+
* The dispatcher runs every authenticator claiming the winning prefix, so
|
|
124
|
+
* this plugin sharing one with another means each request carries two
|
|
125
|
+
* verdicts — and, before the bypass was named, which of them decided the
|
|
126
|
+
* ACLs depended on registration order. A deployment reading as "tokens are
|
|
127
|
+
* scoped to their tenant's branches" deserves to hear that on the first
|
|
128
|
+
* line of its log rather than from an incident.
|
|
129
|
+
*/
|
|
130
|
+
protected identitySource(): string;
|
|
131
|
+
afterLoad(): void;
|
|
132
|
+
/**
|
|
133
|
+
* The verdict `authMethod` could not reach on its own.
|
|
134
|
+
*
|
|
135
|
+
* A bypass is written in terms of the identity another authenticator
|
|
136
|
+
* publishes, and the dispatcher runs them in registration order: asked
|
|
137
|
+
* first, this plugin sees no identity yet and would refuse the very
|
|
138
|
+
* request the bypass is for — order deciding the answer again, which is
|
|
139
|
+
* the shape of the bug this fixes. So `authMethod` defers, and the
|
|
140
|
+
* dispatcher calls this once every authenticator has run and before any
|
|
141
|
+
* route: `req.user` is final here, and nothing has answered yet.
|
|
142
|
+
*
|
|
143
|
+
* A middleware of our own would not do. `api()` runs during registration,
|
|
144
|
+
* and this plugin is not in `priority.json` — nor could it be, since a
|
|
145
|
+
* named instance is matched by exact string and falls back into the
|
|
146
|
+
* parallel batch — so the middleware would land wherever this plugin fell
|
|
147
|
+
* among the others, and a route plugin registered first would answer
|
|
148
|
+
* before the refusal.
|
|
149
|
+
*
|
|
150
|
+
* @param req request whose verdict was deferred
|
|
151
|
+
* @param res response, ended when the request is refused
|
|
152
|
+
* @param next continuation, called when it is allowed
|
|
153
|
+
*/
|
|
154
|
+
afterChain(req: DmRequest, res: Response, next: () => void): void;
|
|
155
|
+
/**
|
|
156
|
+
* Refuse a request carrying no token of ours — unless a bypass may cover
|
|
157
|
+
* it, in which case the verdict waits for the rest of the chain.
|
|
158
|
+
*
|
|
159
|
+
* A bypass names the identity another authenticator publishes, and the
|
|
160
|
+
* dispatcher runs them in registration order: asked first, this plugin
|
|
161
|
+
* sees no identity yet, and refusing there would make the answer depend on
|
|
162
|
+
* that order again. So the request is marked and the middleware mounted in
|
|
163
|
+
* `api()` decides, after every authenticator has run.
|
|
164
|
+
*
|
|
165
|
+
* @param req request with no usable token
|
|
166
|
+
* @param res response, ended when the request is refused
|
|
167
|
+
* @param next continuation, called when the verdict is deferred
|
|
168
|
+
* @param warning what to log when it is refused outright
|
|
169
|
+
*/
|
|
170
|
+
private refuseOrDefer;
|
|
95
171
|
authMethod(req: DmRequest, res: Response, next: () => void): void;
|
|
96
172
|
/**
|
|
97
173
|
* Authorization hooks. These run after the auth middleware has populated
|
|
@@ -23,6 +23,17 @@ export default class AuthzPerBranch extends AuthzBase {
|
|
|
23
23
|
* Resolve user - for authzPerBranch, we use uid directly
|
|
24
24
|
*/
|
|
25
25
|
resolveUser(uid: string): Promise<string | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Say, once every plugin is loaded, when no configured user can ever be
|
|
28
|
+
* the caller.
|
|
29
|
+
*
|
|
30
|
+
* A `authz_per_branch_config.users` key is an identity, and which values
|
|
31
|
+
* an authenticator publishes differs per plugin: keys written for token
|
|
32
|
+
* names are inert behind OpenID Connect, where the identity is a `sub`.
|
|
33
|
+
* The permissions then fall back to `default`, which reads as a
|
|
34
|
+
* configuration that is working.
|
|
35
|
+
*/
|
|
36
|
+
afterLoad(): void;
|
|
26
37
|
/**
|
|
27
38
|
* Override to add authConfig check
|
|
28
39
|
*/
|
|
@@ -14,9 +14,35 @@ export default class AuthzPerRoute extends DmPlugin {
|
|
|
14
14
|
name: string;
|
|
15
15
|
roles: Role[];
|
|
16
16
|
private rules;
|
|
17
|
+
/** Said once: a rule keyed on a value the authenticator did not publish. */
|
|
18
|
+
private fallbackWarned;
|
|
19
|
+
/** Routes already reported as reached with no identity. */
|
|
20
|
+
private unidentifiedRoutes;
|
|
21
|
+
/** How many such lines have been written at `warn`. */
|
|
22
|
+
private unidentifiedWarnings;
|
|
23
|
+
/**
|
|
24
|
+
* How many of those lines to write, and how many routes to remember.
|
|
25
|
+
*
|
|
26
|
+
* The bound that matters is on the *log*, not on the set: `req.path` is a
|
|
27
|
+
* concrete path, identifiers included, so a client decides how many
|
|
28
|
+
* distinct ones exist. Bounding only the set leaves every path it never
|
|
29
|
+
* had room for unseen for ever, and each request for one writes the line
|
|
30
|
+
* again — the bound holds the memory and loses the thing it was there to
|
|
31
|
+
* protect.
|
|
32
|
+
*/
|
|
33
|
+
private static readonly UNIDENTIFIED_MAX;
|
|
17
34
|
constructor(...args: ConstructorParameters<typeof DmPlugin>);
|
|
18
35
|
private parseEntry;
|
|
19
36
|
private addRule;
|
|
20
37
|
private matches;
|
|
38
|
+
/**
|
|
39
|
+
* Say, once every plugin is loaded, when no rule can ever match.
|
|
40
|
+
*
|
|
41
|
+
* The rules are keyed on identities, and which values an authenticator
|
|
42
|
+
* publishes is the whole subject of #187: a rule written for a token name
|
|
43
|
+
* is inert behind OpenID Connect, where the identity is a `sub`. Inert is
|
|
44
|
+
* the part worth a line — nothing is refused, so nothing looks wrong.
|
|
45
|
+
*/
|
|
46
|
+
afterLoad(): void;
|
|
21
47
|
api(app: Express): void;
|
|
22
48
|
}
|
|
@@ -24,11 +24,52 @@ export default class AuthzScope extends DmPlugin {
|
|
|
24
24
|
*/
|
|
25
25
|
api(app: Express): void;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* Whether a plugin can say who may do what where.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* The `authz` role says a plugin restricts something, not that it can
|
|
30
|
+
* answer that: `authzPerRoute` gates URLs and `authzDynamic` reads a token,
|
|
31
|
+
* and neither resolves a user or a branch. Both carry the role, and
|
|
32
|
+
* `authzPerRoute` sits in priority.json, so it is registered before any
|
|
33
|
+
* branch-level plugin — picking by role alone made this endpoint answer 500
|
|
34
|
+
* to every caller on any server combining the two. The capability is what
|
|
35
|
+
* is asked for.
|
|
36
|
+
*
|
|
37
|
+
* @param plugin a loaded plugin
|
|
38
|
+
* @returns true when it resolves users and reads branch permissions
|
|
39
|
+
*/
|
|
40
|
+
private static canDescribe;
|
|
41
|
+
/** @returns the loaded plugins able to describe a scope, in load order */
|
|
42
|
+
private describers;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a plugin judges this request.
|
|
45
|
+
*
|
|
46
|
+
* One that authenticates too — `authzDynamic` — judges the requests it
|
|
47
|
+
* vouched for, which carry its token; the others, the requests of the
|
|
48
|
+
* authenticators `--authz-for` gives them, and only when they carry an
|
|
49
|
+
* identity: an anonymous request passes every one of them.
|
|
50
|
+
*
|
|
51
|
+
* @param plugin an authorization plugin
|
|
52
|
+
* @param req the request
|
|
53
|
+
* @returns true when the plugin's verdict applies to it
|
|
54
|
+
*/
|
|
55
|
+
private static judges;
|
|
56
|
+
/**
|
|
57
|
+
* Refuse a `--authz-scope-source` naming nothing that can describe a scope.
|
|
58
|
+
*
|
|
59
|
+
* Read leniently, a typo would fall back to the first plugin loaded, which
|
|
60
|
+
* is the choice the option exists to take away from load order.
|
|
61
|
+
*/
|
|
62
|
+
assertComposition(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Say, at startup, when the scope depends on which plugin loaded first.
|
|
65
|
+
*
|
|
66
|
+
* Two plugins able to describe a scope, both judging one caller, is two
|
|
67
|
+
* models of what that caller may do; the hooks apply both, and this
|
|
68
|
+
* endpoint can only describe one. Without `--authz-scope-source` it is
|
|
69
|
+
* the first loaded — import-completion order for plugins outside
|
|
70
|
+
* priority.json, which is not a decision anyone took.
|
|
30
71
|
*/
|
|
31
|
-
|
|
72
|
+
afterLoad(): void;
|
|
32
73
|
/**
|
|
33
74
|
* Entities a client can create, as declared by the loaded plugins.
|
|
34
75
|
*
|
|
@@ -2,6 +2,8 @@ import type { Response } from 'express';
|
|
|
2
2
|
import AuthBase, { type DmRequest } from '../../lib/auth/base';
|
|
3
3
|
import type { Role } from '../../abstract/plugin';
|
|
4
4
|
export default class AuthHmac extends AuthBase {
|
|
5
|
+
protected knownIdentities(): string[];
|
|
6
|
+
protected identitySource(): string;
|
|
5
7
|
name: string;
|
|
6
8
|
roles: Role[];
|
|
7
9
|
private services;
|
|
@@ -32,6 +32,45 @@ export default class AuthLLNG extends AuthBase {
|
|
|
32
32
|
* dependency being absent, without actually uninstalling it.
|
|
33
33
|
*/
|
|
34
34
|
protected loadHandler(): Promise<LlngHandler>;
|
|
35
|
+
/**
|
|
36
|
+
* The header the handler writes the identity to.
|
|
37
|
+
*
|
|
38
|
+
* `lemonldap-ng-handler` 3.x assigns it with that exact spelling —
|
|
39
|
+
* `req.headers["Lm-Remote-User"] = session[this.tsv.whatToTrace]` in its
|
|
40
|
+
* `sendHeaders` — while Node lower-cases every header that arrives on the
|
|
41
|
+
* wire. The two never collide, which is what keeps a client from
|
|
42
|
+
* supplying its own identity, and nothing in either project promises that
|
|
43
|
+
* spelling: `dropForgedIdentity` below removes what a client sent, so the
|
|
44
|
+
* plugin can then read whatever case the handler used.
|
|
45
|
+
*/
|
|
46
|
+
private static readonly USER_HEADER;
|
|
47
|
+
/**
|
|
48
|
+
* Remove any identity header the request arrived with.
|
|
49
|
+
*
|
|
50
|
+
* Only the handler may name the caller. A client sending
|
|
51
|
+
* `Lm-Remote-User: admin` reaches this plugin as `lm-remote-user`, and the
|
|
52
|
+
* read below is case-insensitive, so without this it would be read as an
|
|
53
|
+
* identity the handler never vouched for.
|
|
54
|
+
*
|
|
55
|
+
* @param req incoming request, whose headers are pruned in place
|
|
56
|
+
*/
|
|
57
|
+
private static dropForgedIdentity;
|
|
58
|
+
/**
|
|
59
|
+
* A header's value, whatever case it was written in.
|
|
60
|
+
*
|
|
61
|
+
* @param req request the handler has just passed on
|
|
62
|
+
* @param lowercased the header's name, already lower-cased
|
|
63
|
+
* @returns its value, or undefined when it is not there
|
|
64
|
+
*/
|
|
65
|
+
private static headerValue;
|
|
66
|
+
/**
|
|
67
|
+
* The identity the handler vouched for, whatever case it wrote it in.
|
|
68
|
+
*
|
|
69
|
+
* @param req request the handler has just passed on
|
|
70
|
+
* @returns the identity, or undefined when the handler named nobody
|
|
71
|
+
*/
|
|
72
|
+
private static vouchedIdentity;
|
|
73
|
+
protected identitySource(): string;
|
|
35
74
|
authMethod(req: DmRequest, res: Response, next: () => void): void;
|
|
36
75
|
}
|
|
37
76
|
export {};
|
|
@@ -1,11 +1,74 @@
|
|
|
1
1
|
import type { Express, Response } from 'express';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import type { ConfigParams } from 'express-openid-connect';
|
|
3
|
+
import AuthBase, { DmRequest } from '../../lib/auth/base';
|
|
4
|
+
import { type Role } from '../../abstract/plugin';
|
|
4
5
|
import { DM } from '../../bin';
|
|
5
|
-
export default class OpenIDConnect extends
|
|
6
|
+
export default class OpenIDConnect extends AuthBase {
|
|
6
7
|
name: string;
|
|
7
8
|
roles: Role[];
|
|
9
|
+
/** The library's router, built once: `auth()` returns a fresh one per call. */
|
|
10
|
+
private router?;
|
|
8
11
|
constructor(server: DM);
|
|
12
|
+
/**
|
|
13
|
+
* What is handed to `express-openid-connect`.
|
|
14
|
+
*
|
|
15
|
+
* Built apart from `api` so it can be looked at: leaving `onLogin` out of
|
|
16
|
+
* `backchannelLogout` made the library run its own, which reaches for a
|
|
17
|
+
* store nothing configured here and threw on every callback — a 500 on
|
|
18
|
+
* every login, with the session cookie already set.
|
|
19
|
+
*/
|
|
20
|
+
buildConfig(): ConfigParams;
|
|
21
|
+
protected identitySource(): string;
|
|
22
|
+
/**
|
|
23
|
+
* Paths this authentication claims.
|
|
24
|
+
*
|
|
25
|
+
* Its own routes come with it. Scoped to `/api/admin`, the plugin would
|
|
26
|
+
* otherwise leave `/callback` to whatever else guards the server — a
|
|
27
|
+
* catch-all token plugin answering the provider's redirect with a 401 —
|
|
28
|
+
* so the four are claimed too, which also keeps them out of every other
|
|
29
|
+
* plugin's catch-all.
|
|
30
|
+
*
|
|
31
|
+
* Unscoped, the plugin already guards everything and the list stays empty.
|
|
32
|
+
*
|
|
33
|
+
* @returns the prefixes the dispatcher routes here
|
|
34
|
+
*/
|
|
35
|
+
get pathPrefixes(): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Register with the dispatcher, like every other authentication plugin.
|
|
38
|
+
*
|
|
39
|
+
* This plugin used to mount three layers of its own — the `beforeAuth`
|
|
40
|
+
* hooks, the library's router, then the identity — which kept their
|
|
41
|
+
* registration order. An instance carrying a name, the only form that can
|
|
42
|
+
* hold `auth_path_prefix`, landed in the parallel batch and mounted after
|
|
43
|
+
* the plugins that were supposed to read what it publishes: with
|
|
44
|
+
* `core/auth/authzPerRoute` loaded, every rule was judged before
|
|
45
|
+
* `req.user` existed, and `authzPerRoute` passes a request it cannot
|
|
46
|
+
* identify. The rules read as enforced and were inert.
|
|
47
|
+
*
|
|
48
|
+
* The dispatcher is mounted before any plugin can register a route, runs
|
|
49
|
+
* only the plugin whose claim is most specific, and hands `authMethod`
|
|
50
|
+
* the `beforeAuth`/`afterAuth` hooks — the three layers, in a place where
|
|
51
|
+
* order is not a configuration accident. (`afterAuth` used to be gated on
|
|
52
|
+
* a flag nothing sets, so this plugin's own copy of it never ran either.)
|
|
53
|
+
*
|
|
54
|
+
* @param app the express application
|
|
55
|
+
*/
|
|
9
56
|
api(app: Express): void;
|
|
57
|
+
/**
|
|
58
|
+
* Read the session, then require one.
|
|
59
|
+
*
|
|
60
|
+
* `requiresAuth()` runs *after* the router, so `req.oidc` exists when the
|
|
61
|
+
* decision is taken. It redirects a browser to the provider and answers
|
|
62
|
+
* 401 to an API client, which is the library's default and what makes a
|
|
63
|
+
* login work at all; the other authentication plugins answer JSON 401
|
|
64
|
+
* throughout, and that difference is documented rather than smoothed over.
|
|
65
|
+
*
|
|
66
|
+
* The plugin's own routes never reach the second step: the router answers
|
|
67
|
+
* them itself.
|
|
68
|
+
*
|
|
69
|
+
* @param req incoming request
|
|
70
|
+
* @param res response, ended by the library when it refuses or redirects
|
|
71
|
+
* @param next called once the request carries an identity
|
|
72
|
+
*/
|
|
10
73
|
authMethod(req: DmRequest, res: Response, next: () => void): void;
|
|
11
74
|
}
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* to protect against brute-force attacks. It should be loaded BEFORE auth plugins.
|
|
7
7
|
*
|
|
8
8
|
* Uses an Express middleware to check rate limits before processing, and
|
|
9
|
-
* an afterAuth hook
|
|
9
|
+
* a middleware per request. It describes an `afterAuth` hook tracking failed
|
|
10
|
+
* authentication attempts, which it has never registered — the counting is
|
|
11
|
+
* per request, whatever the request turns out to be.
|
|
10
12
|
*
|
|
11
13
|
* @author Xavier Guimard <xguimard@linagora.com>
|
|
12
14
|
*/
|
|
@@ -9,6 +9,8 @@ import type { Response } from 'express';
|
|
|
9
9
|
import AuthBase, { type DmRequest } from '../../lib/auth/base';
|
|
10
10
|
import type { Role } from '../../abstract/plugin';
|
|
11
11
|
export default class AuthToken extends AuthBase {
|
|
12
|
+
protected knownIdentities(): string[];
|
|
13
|
+
protected identitySource(): string;
|
|
12
14
|
name: string;
|
|
13
15
|
roles: Role[];
|
|
14
16
|
private tokenMap;
|
|
@@ -2,6 +2,8 @@ import type { Response } from 'express';
|
|
|
2
2
|
import AuthBase, { type DmRequest } from '../../lib/auth/base';
|
|
3
3
|
import type { Role } from '../../abstract/plugin';
|
|
4
4
|
export default class AuthTotp extends AuthBase {
|
|
5
|
+
protected knownIdentities(): string[];
|
|
6
|
+
protected identitySource(): string;
|
|
5
7
|
name: string;
|
|
6
8
|
roles: Role[];
|
|
7
9
|
private totpUsers;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module plugins/bcl
|
|
3
|
+
*
|
|
4
|
+
* Back-Channel Logout: what a provider tells us is dead, and what we do with
|
|
5
|
+
* it on the next request.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here stores a session. `core/auth/openidconnect` keeps that in its
|
|
8
|
+
* cookie; a logout token only leaves a **tombstone** saying that one `sid`, or
|
|
9
|
+
* every session of one `sub`, is dead, and each request asks whether such a
|
|
10
|
+
* mark exists. Recording only what is dead is what keeps the sessions
|
|
11
|
+
* stateless and the storage small.
|
|
12
|
+
*
|
|
13
|
+
* Where the marks live is `core/storage`'s business, not this plugin's. What
|
|
14
|
+
* stays here is the policy: which keys a token kills, how long a mark is
|
|
15
|
+
* kept, and what a login forgets.
|
|
16
|
+
*
|
|
17
|
+
* @group Plugins
|
|
18
|
+
*/
|
|
19
|
+
import DmPlugin from '../abstract/plugin';
|
|
20
|
+
import type { DM } from '../bin';
|
|
21
|
+
import type { OidcLogoutToken, OidcSessionClaims } from '../hooks';
|
|
22
|
+
export default class Bcl extends DmPlugin {
|
|
23
|
+
name: string;
|
|
24
|
+
dependencies: {
|
|
25
|
+
storage: string;
|
|
26
|
+
};
|
|
27
|
+
private storage?;
|
|
28
|
+
/** How long a tombstone is kept, in milliseconds. */
|
|
29
|
+
private retention;
|
|
30
|
+
constructor(server: DM);
|
|
31
|
+
/**
|
|
32
|
+
* Take hold of the store.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately not the constructor. A plugin is built before
|
|
35
|
+
* `registerPlugin` reads its `dependencies` and loads what they name, so a
|
|
36
|
+
* constructor asking for the store answers for the order of two `--plugin`
|
|
37
|
+
* arguments rather than for the configuration: `core/bcl` listed first
|
|
38
|
+
* would be refused for a storage that is right there, and the declaration
|
|
39
|
+
* above — the thing meant to make order irrelevant — would have loaded it
|
|
40
|
+
* a moment later. `api()` runs after that loop, which is where
|
|
41
|
+
* `requirePlugin`'s own promise starts holding.
|
|
42
|
+
*/
|
|
43
|
+
api(): void;
|
|
44
|
+
/**
|
|
45
|
+
* The store, once `api()` has taken hold of it.
|
|
46
|
+
*
|
|
47
|
+
* `registerPlugin` calls `api()` before it subscribes anything, so a hook
|
|
48
|
+
* running without one means a plugin built by hand and never registered —
|
|
49
|
+
* a test, in practice. Saying so beats reading `undefined` as "nothing is
|
|
50
|
+
* recorded", which is the fail-open this plugin exists to prevent.
|
|
51
|
+
*/
|
|
52
|
+
private get store();
|
|
53
|
+
/**
|
|
54
|
+
* The keys a token kills.
|
|
55
|
+
*
|
|
56
|
+
* A provider sends `sid`, or `sub`, or both: `sid` ends one session, `sub`
|
|
57
|
+
* ends every session of that person.
|
|
58
|
+
*/
|
|
59
|
+
private static keys;
|
|
60
|
+
hooks: {
|
|
61
|
+
oidclogouttoken: (token: OidcLogoutToken) => Promise<void>;
|
|
62
|
+
oidclogin: (claims: OidcSessionClaims) => Promise<void>;
|
|
63
|
+
oidcsessionvalid: ([claims, valid]: [OidcSessionClaims, boolean]) => Promise<[OidcSessionClaims, boolean]>;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -139,6 +139,80 @@ export default class LdapOrganizations extends DmPlugin {
|
|
|
139
139
|
getOrganisationTop(req?: Request): Promise<AttributesList | AttributesList[]>;
|
|
140
140
|
getOrganisationByDn(dn: string, req?: Request): Promise<AttributesList>;
|
|
141
141
|
getOrganisationSubnodes(dn: string, req?: Request): Promise<AttributesList[]>;
|
|
142
|
+
/** Nodes already warned about, so a crowded tree says it once per node. */
|
|
143
|
+
private sizeLimitWarned;
|
|
144
|
+
/** How many of them to remember. */
|
|
145
|
+
private static readonly SIZE_LIMIT_WARNED_MAX;
|
|
146
|
+
/**
|
|
147
|
+
* Say once per node what only the directory can fix.
|
|
148
|
+
*
|
|
149
|
+
* A console expanding a tree lists the same crowded node on every refresh,
|
|
150
|
+
* and a warning per listing buries the rest of the log while saying
|
|
151
|
+
* nothing new: the answer stays partial until someone raises the size
|
|
152
|
+
* limit. The first listing carries the message, the others go to `debug`.
|
|
153
|
+
*
|
|
154
|
+
* The set is bounded, so a directory with thousands of crowded nodes
|
|
155
|
+
* cannot grow it without end. Past the bound the message is written every
|
|
156
|
+
* time — the loud side of the trade rather than the silent one.
|
|
157
|
+
*
|
|
158
|
+
* @param key node the message is about
|
|
159
|
+
* @param message what to say
|
|
160
|
+
*/
|
|
161
|
+
private warnSizeLimit;
|
|
162
|
+
/**
|
|
163
|
+
* The child organizations of a node, and what to do when the directory
|
|
164
|
+
* will not list them all.
|
|
165
|
+
*
|
|
166
|
+
* Every failure used to be read as "no children" (#179): the node's own
|
|
167
|
+
* children were searched without a limit, and a directory answering
|
|
168
|
+
* `sizeLimitExceeded` rather than a long list left the endpoint returning
|
|
169
|
+
* `200 []` with nothing above `debug` to say so. An empty tree is what a
|
|
170
|
+
* console then draws for a branch holding a thousand organizations.
|
|
171
|
+
*
|
|
172
|
+
* Three outcomes, and only one of them is emptiness:
|
|
173
|
+
*
|
|
174
|
+
* - `noSuchObject` (32): nothing is there. An empty answer is the truth.
|
|
175
|
+
* - `sizeLimitExceeded` (4): the answer was longer than the server's
|
|
176
|
+
* limit. Measured against OpenLDAP 2.5, the paged control does not lift
|
|
177
|
+
* it — `size.prtotal` bounds the whole search, so a paged walk ends on
|
|
178
|
+
* the same refusal. What does change the answer is asking for a bounded
|
|
179
|
+
* number: ldapts raises the refusal only when the request carried no
|
|
180
|
+
* `sizeLimit` of its own, so a second, bounded search comes back with
|
|
181
|
+
* entries where the first came back with an error. The answer then says
|
|
182
|
+
* it is partial, through the `moreIndicator` row the attached entries
|
|
183
|
+
* already use.
|
|
184
|
+
* - anything else is a failure, and answering `[]` to a failure is how a
|
|
185
|
+
* broken directory comes to look exactly like an empty one.
|
|
186
|
+
*
|
|
187
|
+
* The first search is paged for the directories where paging *is* what
|
|
188
|
+
* lifts the limit: Active Directory answers an unpaged search with at most
|
|
189
|
+
* `MaxPageSize` entries and expects the control for the rest.
|
|
190
|
+
*
|
|
191
|
+
* @param dn organization whose children are wanted
|
|
192
|
+
* @param filter what those children have to match
|
|
193
|
+
* @param cap how many to keep when the directory will not list them all
|
|
194
|
+
* @param req incoming request, forwarded to the authorization hooks
|
|
195
|
+
* @returns the children, followed by a `moreIndicator` row when the
|
|
196
|
+
* directory refused to list them all
|
|
197
|
+
*/
|
|
198
|
+
private childOrganizations;
|
|
199
|
+
/**
|
|
200
|
+
* The entries attached to an organization without being one.
|
|
201
|
+
*
|
|
202
|
+
* Capped at `cap`, with a `moreIndicator` row counting what was left out —
|
|
203
|
+
* the behaviour this endpoint already had, plus the refusal the children
|
|
204
|
+
* search just learned to survive: a directory that will not walk the whole
|
|
205
|
+
* link filter is told about rather than turned into an empty list.
|
|
206
|
+
*
|
|
207
|
+
* @param baseDn branch the attached entries live in
|
|
208
|
+
* @param filter what links them to the organization
|
|
209
|
+
* @param cap how many to return
|
|
210
|
+
* @param dn organization they are attached to, for the indicator's DN
|
|
211
|
+
* @param req incoming request, forwarded to the authorization hooks
|
|
212
|
+
* @returns the entries, followed by a `moreIndicator` row when some were
|
|
213
|
+
* left out
|
|
214
|
+
*/
|
|
215
|
+
private linkedEntities;
|
|
142
216
|
addOrganization(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
|
|
143
217
|
modifyOrganization(dn: string, changes: ModifyRequest): Promise<boolean>;
|
|
144
218
|
validateNewOrganization(dn: string, entry: AttributesList): Promise<boolean>;
|
|
@@ -168,7 +242,22 @@ export default class LdapOrganizations extends DmPlugin {
|
|
|
168
242
|
newDn: string;
|
|
169
243
|
}>;
|
|
170
244
|
deleteOrganization(dn: string): Promise<boolean>;
|
|
171
|
-
|
|
245
|
+
/**
|
|
246
|
+
* The same two searches as `getOrganisationSubnodes`, narrowed by a query.
|
|
247
|
+
*
|
|
248
|
+
* Both go through the helpers that classify a failure rather than reading
|
|
249
|
+
* every one of them as emptiness (#179), and both now carry the request:
|
|
250
|
+
* they ran without one, and an authorization plugin skips its check when
|
|
251
|
+
* there is none — the same gap the flat routes had until 0.8.2. The
|
|
252
|
+
* attached entries are capped like the other endpoint's, since an answer
|
|
253
|
+
* the directory refuses to finish is what this is about.
|
|
254
|
+
*
|
|
255
|
+
* @param dn organization to search under
|
|
256
|
+
* @param query what to look for in a name, a description or an identity
|
|
257
|
+
* @param req incoming request, forwarded to the authorization hooks
|
|
258
|
+
* @returns matching organizations then matching attached entries
|
|
259
|
+
*/
|
|
260
|
+
searchOrganisationSubnodes(dn: string, query: string, req?: Request): Promise<AttributesList[]>;
|
|
172
261
|
/**
|
|
173
262
|
* Provide configuration for config API
|
|
174
263
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Config } from '../../config/args';
|
|
2
|
-
import type
|
|
2
|
+
import { type DmRequest } from '../../lib/auth/base';
|
|
3
3
|
export interface BaseMapEntry {
|
|
4
4
|
userBase?: string;
|
|
5
5
|
groupBase?: string;
|
|
@@ -14,6 +14,8 @@ export declare class BaseResolver {
|
|
|
14
14
|
private readonly userBaseHeader;
|
|
15
15
|
private readonly groupBaseHeader;
|
|
16
16
|
private readonly headerRoot;
|
|
17
|
+
/** Kept so a base is keyed on whatever `--authz-identity` names. */
|
|
18
|
+
private readonly config;
|
|
17
19
|
constructor(config: Config);
|
|
18
20
|
private applyTemplate;
|
|
19
21
|
/** Read a request header by name, tolerating Express req or a plain object. */
|