ldap-rest 0.8.2 → 0.10.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/bin/index.mjs +1 -1
- 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/changeContext.js +1 -0
- package/dist/lib/errors.js +1 -1
- 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/authzLinid1.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/departmentSync.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/trash.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/scim.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/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/plugins/twake/clouderyProvision.js +1 -1
- package/dist/plugins/twake/cozyProvision.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +7 -0
- package/dist/src/abstract/plugin.d.ts +26 -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 +70 -6
- package/dist/src/lib/auth/base.d.ts +175 -9
- package/dist/src/lib/authz/base.d.ts +159 -4
- package/dist/src/lib/authz/composition.d.ts +145 -0
- package/dist/src/lib/changeContext.d.ts +24 -0
- package/dist/src/lib/errors.d.ts +15 -0
- package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
- package/dist/src/lib/ldapActions.d.ts +32 -1
- 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/authzLinid1.d.ts +6 -0
- package/dist/src/plugins/auth/authzPerBranch.d.ts +46 -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/groups.d.ts +7 -5
- package/dist/src/plugins/ldap/onChange.d.ts +17 -8
- package/dist/src/plugins/ldap/organizations.d.ts +92 -3
- 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 +13 -19
- package/docs/plugin-development/dependencies.md +40 -15
- package/docs/plugin-development/hooks.md +6 -0
- package/docs/plugin-development/testing.md +1 -17
- package/docs/usage/configuration.md +90 -14
- 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 +33 -0
- package/docs/usage/plugins/auth/authz-per-branch.md +41 -11
- package/docs/usage/plugins/auth/authz-per-route.md +6 -0
- package/docs/usage/plugins/auth/authz-scope.md +74 -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/on-change.md +51 -284
- package/docs/usage/plugins/ldap/organizations.md +37 -0
- package/docs/usage/plugins/utilities/storage.md +94 -0
- package/docs/usage/troubleshooting.md +1 -9
- package/docs/usage/upgrading.md +341 -0
- package/package.json +17 -1
|
@@ -117,11 +117,41 @@ declare class ldapActions {
|
|
|
117
117
|
* below it and the extra test costs nothing.
|
|
118
118
|
*/
|
|
119
119
|
invalidateCache(dn: string): void;
|
|
120
|
+
/**
|
|
121
|
+
* The directory as the server itself, with no request behind it.
|
|
122
|
+
*
|
|
123
|
+
* Every authorization plugin skips its check when a call carries no
|
|
124
|
+
* request, so an unbound call is a bypass — a deliberate one where the
|
|
125
|
+
* work belongs to nobody in particular: a startup task, a cron job, a
|
|
126
|
+
* uniqueness check, a referential-integrity check. Those must see the
|
|
127
|
+
* whole directory to be correct: a caller who cannot *read* `uid=jdoe`
|
|
128
|
+
* must still not be handed it as a free identifier, and a reference to an
|
|
129
|
+
* organization they cannot read is still a valid reference.
|
|
130
|
+
*
|
|
131
|
+
* The methods are the same objects as this instance's own — writes
|
|
132
|
+
* included, since a scheduled task writes: the examples above are reads
|
|
133
|
+
* because that is where the ambiguity lives, not because a write cannot
|
|
134
|
+
* belong to nobody. What this adds is a name: `ldap.system.search(…)` says
|
|
135
|
+
* the omission was meant, where `ldap.search(…)` says nothing and looks
|
|
136
|
+
* exactly like the mistake it takes one review to miss. A plugin serving a
|
|
137
|
+
* request should reach for {@link forRequest} instead.
|
|
138
|
+
*/
|
|
139
|
+
get system(): this;
|
|
120
140
|
/**
|
|
121
141
|
* Bind every directory operation to one request, so the authorization
|
|
122
142
|
* hooks always see it. See {@link RequestBoundLdap}.
|
|
123
143
|
*/
|
|
124
144
|
forRequest(req: Request): RequestBoundLdap;
|
|
145
|
+
/**
|
|
146
|
+
* Hand a search result to `ldapsearchfilter`, which may drop entries the
|
|
147
|
+
* caller is not allowed to see.
|
|
148
|
+
*
|
|
149
|
+
* Applied on every return path of `search`, and deliberately after the
|
|
150
|
+
* cache: `ldapsearchresult` fires before caching and knows no request, so
|
|
151
|
+
* what it removed for one caller would be served to the next. A paginated
|
|
152
|
+
* search is filtered chunk by chunk, as it is consumed.
|
|
153
|
+
*/
|
|
154
|
+
private filterForCaller;
|
|
125
155
|
search(options: SearchOptions, base?: string, req?: Request): Promise<SearchResult | AsyncGenerator<SearchResult>>;
|
|
126
156
|
/**
|
|
127
157
|
* Wrap paginated search to ensure connection is released when done
|
|
@@ -142,8 +172,9 @@ declare class ldapActions {
|
|
|
142
172
|
* @param dn - Current DN (e.g., "uid=user1,ou=users,dc=example,dc=com")
|
|
143
173
|
* @param newDn - Full new DN (e.g., "uid=user1,ou=trash,dc=example,dc=com")
|
|
144
174
|
* ldapts will extract newRDN="uid=user1" and newSuperior="ou=trash,dc=example,dc=com"
|
|
175
|
+
* @param req - Request behind the move, when there is one
|
|
145
176
|
*/
|
|
146
|
-
move(dn: string, newDn: string): Promise<boolean>;
|
|
177
|
+
move(dn: string, newDn: string, req?: Request): Promise<boolean>;
|
|
147
178
|
delete(dn: string | string[], req?: Request): Promise<boolean>;
|
|
148
179
|
private setDn;
|
|
149
180
|
opNumber(): number;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module lib/ldapCodes
|
|
3
|
+
*
|
|
4
|
+
* Reading an LDAP result code off a thrown error.
|
|
5
|
+
*
|
|
6
|
+
* Callers have to tell one failure from another — noSuchObject from
|
|
7
|
+
* entryAlreadyExists, a size limit from a server fault — and the code is the
|
|
8
|
+
* only part of a failure the protocol defines. The wording is the driver's,
|
|
9
|
+
* and no driver promises to keep it.
|
|
10
|
+
*
|
|
11
|
+
* This lived in `plugins/scim/errors` until an LDAP plugin needed it too;
|
|
12
|
+
* nothing in it is SCIM's business, so it is here and SCIM re-exports it.
|
|
13
|
+
*
|
|
14
|
+
* @group Libraries
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Extract an LDAP numeric error code from a thrown ldapts error.
|
|
18
|
+
*
|
|
19
|
+
* `.code` first: ldapts sets it, and `ldapActions` carries it across its own
|
|
20
|
+
* wrapping rather than reducing the failure to a sentence. The message
|
|
21
|
+
* patterns below stay as a fallback for the paths that still throw a bare
|
|
22
|
+
* `Error` — they are why a driver that reworded its messages could quietly
|
|
23
|
+
* turn a 400 into a 500, so they are the last resort, not the first.
|
|
24
|
+
*
|
|
25
|
+
* @param err whatever was thrown
|
|
26
|
+
* @returns the LDAP result code, or undefined when nothing says
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractLdapCode(err: unknown): number | undefined;
|
|
@@ -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,12 @@ export default class AuthzLinid1 extends AuthzBase {
|
|
|
23
23
|
resolveUser(uid: string): Promise<string | null>;
|
|
24
24
|
/**
|
|
25
25
|
* Get user DN from uid
|
|
26
|
+
*
|
|
27
|
+
* An identity naming several entries is refused (`AmbiguousIdentityError`)
|
|
28
|
+
* rather than answered with `null`: under `--authz-unresolved-user allow`,
|
|
29
|
+
* `null` means "skip authorization", so an ambiguous directory would open
|
|
30
|
+
* every branch. A failed search propagates for the same reason — it used
|
|
31
|
+
* to read as `null` too, and `resolveCaller` then cached it.
|
|
26
32
|
*/
|
|
27
33
|
getUserDn(uid: string): Promise<string | null>;
|
|
28
34
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @group Plugins
|
|
8
8
|
*/
|
|
9
9
|
import type { DM } from '../../bin';
|
|
10
|
+
import type { Hooks } from '../../hooks';
|
|
10
11
|
import type { AuthConfig, BranchPermissions } from '../../config/args';
|
|
11
12
|
import type { DmRequest } from '../../lib/auth/base';
|
|
12
13
|
import AuthzBase from '../../lib/authz/base';
|
|
@@ -18,11 +19,40 @@ export default class AuthzPerBranch extends AuthzBase {
|
|
|
18
19
|
name: string;
|
|
19
20
|
authConfig?: AuthConfig;
|
|
20
21
|
groupCache: Map<string, CachedGroups>;
|
|
22
|
+
/** Lookups under way, so concurrent misses for one uid share a search. */
|
|
23
|
+
private pendingGroups;
|
|
24
|
+
/**
|
|
25
|
+
* Bumped by every invalidation. A lookup that started before a write may
|
|
26
|
+
* have read the directory before it: it still answers its own callers,
|
|
27
|
+
* but does not put what it read back into the cache.
|
|
28
|
+
*/
|
|
29
|
+
private groupGeneration;
|
|
30
|
+
/** `authConfig.groups` keyed by normalized DN, and the object it was built from. */
|
|
31
|
+
private groupRulesIndex?;
|
|
32
|
+
hooks: AuthzBase['hooks'] & Pick<Hooks, 'ldapadddone' | 'ldapmodifydone' | 'ldapdeletedone'>;
|
|
21
33
|
constructor(server: DM);
|
|
34
|
+
/**
|
|
35
|
+
* Whether a modify can change which groups a uid belongs to: it touches
|
|
36
|
+
* the member attribute, or the attribute a uid is resolved by.
|
|
37
|
+
*/
|
|
38
|
+
private touchesMembership;
|
|
39
|
+
/** Drop every cached and pending group lookup. */
|
|
40
|
+
forgetGroups(): void;
|
|
22
41
|
/**
|
|
23
42
|
* Resolve user - for authzPerBranch, we use uid directly
|
|
24
43
|
*/
|
|
25
44
|
resolveUser(uid: string): Promise<string | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Say, once every plugin is loaded, when no configured user can ever be
|
|
47
|
+
* the caller.
|
|
48
|
+
*
|
|
49
|
+
* A `authz_per_branch_config.users` key is an identity, and which values
|
|
50
|
+
* an authenticator publishes differs per plugin: keys written for token
|
|
51
|
+
* names are inert behind OpenID Connect, where the identity is a `sub`.
|
|
52
|
+
* The permissions then fall back to `default`, which reads as a
|
|
53
|
+
* configuration that is working.
|
|
54
|
+
*/
|
|
55
|
+
afterLoad(): void;
|
|
26
56
|
/**
|
|
27
57
|
* Override to add authConfig check
|
|
28
58
|
*/
|
|
@@ -51,9 +81,25 @@ export default class AuthzPerBranch extends AuthzBase {
|
|
|
51
81
|
* Build LDAP filter for authorized branches
|
|
52
82
|
*/
|
|
53
83
|
private buildBranchFilter;
|
|
84
|
+
/**
|
|
85
|
+
* The rules configured for a group, compared as DNs.
|
|
86
|
+
*
|
|
87
|
+
* The directory answers with its own spelling of a group's DN, and the
|
|
88
|
+
* configuration carries whatever an administrator typed: a textual lookup
|
|
89
|
+
* missed `cn=Admins,ou=Groups,…` against `cn=admins, ou=groups,…` and
|
|
90
|
+
* dropped the rule without a word. The index is rebuilt whenever
|
|
91
|
+
* `authConfig.groups` is replaced.
|
|
92
|
+
*/
|
|
93
|
+
private groupRules;
|
|
54
94
|
/**
|
|
55
95
|
* Get user's group memberships with caching
|
|
96
|
+
*
|
|
97
|
+
* Concurrent misses for one uid share a single lookup. What the lookup
|
|
98
|
+
* found is cached, including "no group"; a failed search is not, so an
|
|
99
|
+
* outage does not outlive itself by a TTL.
|
|
56
100
|
*/
|
|
57
101
|
getUserGroups(uid: string): Promise<string[]>;
|
|
102
|
+
/** Read a uid's group memberships from the directory. */
|
|
103
|
+
private lookupGroups;
|
|
58
104
|
}
|
|
59
105
|
export {};
|
|
@@ -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;
|