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.
Files changed (79) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/bin/index.js +1 -1
  3. package/dist/config/args.js +1 -1
  4. package/dist/lib/auth/base.js +1 -1
  5. package/dist/lib/authz/base.js +1 -1
  6. package/dist/lib/authz/composition.js +1 -0
  7. package/dist/lib/expressFormatedResponses.js +1 -1
  8. package/dist/lib/ldapActions.js +1 -1
  9. package/dist/lib/ldapCodes.js +1 -0
  10. package/dist/lib/parseConfig.js +1 -1
  11. package/dist/lib/storage/store.js +1 -0
  12. package/dist/lib/utils.js +1 -1
  13. package/dist/plugins/auth/authzDynamic.js +1 -1
  14. package/dist/plugins/auth/authzPerBranch.js +1 -1
  15. package/dist/plugins/auth/authzPerRoute.js +1 -1
  16. package/dist/plugins/auth/authzScope.js +1 -1
  17. package/dist/plugins/auth/hmac.js +1 -1
  18. package/dist/plugins/auth/llng.js +1 -1
  19. package/dist/plugins/auth/openidconnect.js +1 -1
  20. package/dist/plugins/auth/token.js +1 -1
  21. package/dist/plugins/auth/totp.js +1 -1
  22. package/dist/plugins/bcl.js +1 -0
  23. package/dist/plugins/ldap/organizations.js +1 -1
  24. package/dist/plugins/priority.json.js +1 -1
  25. package/dist/plugins/scim/baseResolver.js +1 -1
  26. package/dist/plugins/scim/errors.js +1 -1
  27. package/dist/plugins/scim/groups.js +1 -1
  28. package/dist/plugins/scim/list.js +1 -1
  29. package/dist/plugins/scim/users.js +1 -1
  30. package/dist/plugins/storage/file.js +1 -0
  31. package/dist/plugins/storage/ldap.js +1 -0
  32. package/dist/plugins/storage.js +1 -0
  33. package/dist/src/abstract/ldapFlat.d.ts +11 -3
  34. package/dist/src/abstract/plugin.d.ts +20 -0
  35. package/dist/src/bin/index.d.ts +50 -8
  36. package/dist/src/config/args.d.ts +36 -0
  37. package/dist/src/hooks.d.ts +55 -0
  38. package/dist/src/lib/auth/base.d.ts +175 -9
  39. package/dist/src/lib/authz/base.d.ts +133 -4
  40. package/dist/src/lib/authz/composition.d.ts +145 -0
  41. package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
  42. package/dist/src/lib/ldapActions.d.ts +30 -0
  43. package/dist/src/lib/ldapCodes.d.ts +28 -0
  44. package/dist/src/lib/storage/store.d.ts +84 -0
  45. package/dist/src/lib/utils.d.ts +19 -0
  46. package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
  47. package/dist/src/plugins/auth/authzPerBranch.d.ts +11 -0
  48. package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
  49. package/dist/src/plugins/auth/authzScope.d.ts +44 -3
  50. package/dist/src/plugins/auth/hmac.d.ts +2 -0
  51. package/dist/src/plugins/auth/llng.d.ts +39 -0
  52. package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
  53. package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
  54. package/dist/src/plugins/auth/token.d.ts +2 -0
  55. package/dist/src/plugins/auth/totp.d.ts +2 -0
  56. package/dist/src/plugins/bcl.d.ts +65 -0
  57. package/dist/src/plugins/ldap/organizations.d.ts +90 -1
  58. package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
  59. package/dist/src/plugins/scim/errors.d.ts +2 -10
  60. package/dist/src/plugins/storage/file.d.ts +19 -0
  61. package/dist/src/plugins/storage/ldap.d.ts +17 -0
  62. package/dist/src/plugins/storage.d.ts +44 -0
  63. package/docs/plugin-development/README.md +12 -1
  64. package/docs/plugin-development/dependencies.md +40 -15
  65. package/docs/usage/configuration.md +89 -13
  66. package/docs/usage/plugins/README.md +17 -12
  67. package/docs/usage/plugins/auth/README.md +129 -0
  68. package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
  69. package/docs/usage/plugins/auth/authz-linid1.md +32 -0
  70. package/docs/usage/plugins/auth/authz-per-branch.md +8 -0
  71. package/docs/usage/plugins/auth/authz-per-route.md +6 -0
  72. package/docs/usage/plugins/auth/authz-scope.md +69 -16
  73. package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
  74. package/docs/usage/plugins/auth/llng.md +29 -0
  75. package/docs/usage/plugins/auth/oidc.md +52 -0
  76. package/docs/usage/plugins/ldap/organizations.md +37 -0
  77. package/docs/usage/plugins/utilities/storage.md +94 -0
  78. package/docs/usage/upgrading.md +289 -0
  79. 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
+ }
@@ -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
- * The authorization plugin in force, if any.
27
+ * Whether a plugin can say who may do what where.
28
28
  *
29
- * @returns the plugin, or undefined when the server is unrestricted
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
- private authz;
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 { DmRequest } from '../../lib/auth/base';
3
- import DmPlugin, { type Role } from '../../abstract/plugin';
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 DmPlugin {
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 to track failed authentication attempts.
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
- searchOrganisationSubnodes(dn: string, query: string): Promise<AttributesList[]>;
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 { DmRequest } from '../../lib/auth/base';
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. */