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.
Files changed (101) hide show
  1. package/bin/index.mjs +1 -1
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/lib/auth/base.js +1 -1
  6. package/dist/lib/authz/base.js +1 -1
  7. package/dist/lib/authz/composition.js +1 -0
  8. package/dist/lib/changeContext.js +1 -0
  9. package/dist/lib/errors.js +1 -1
  10. package/dist/lib/expressFormatedResponses.js +1 -1
  11. package/dist/lib/ldapActions.js +1 -1
  12. package/dist/lib/ldapCodes.js +1 -0
  13. package/dist/lib/parseConfig.js +1 -1
  14. package/dist/lib/storage/store.js +1 -0
  15. package/dist/lib/utils.js +1 -1
  16. package/dist/plugins/auth/authzDynamic.js +1 -1
  17. package/dist/plugins/auth/authzLinid1.js +1 -1
  18. package/dist/plugins/auth/authzPerBranch.js +1 -1
  19. package/dist/plugins/auth/authzPerRoute.js +1 -1
  20. package/dist/plugins/auth/authzScope.js +1 -1
  21. package/dist/plugins/auth/hmac.js +1 -1
  22. package/dist/plugins/auth/llng.js +1 -1
  23. package/dist/plugins/auth/openidconnect.js +1 -1
  24. package/dist/plugins/auth/token.js +1 -1
  25. package/dist/plugins/auth/totp.js +1 -1
  26. package/dist/plugins/bcl.js +1 -0
  27. package/dist/plugins/ldap/departmentSync.js +1 -1
  28. package/dist/plugins/ldap/groups.js +1 -1
  29. package/dist/plugins/ldap/onChange.js +1 -1
  30. package/dist/plugins/ldap/organizations.js +1 -1
  31. package/dist/plugins/ldap/trash.js +1 -1
  32. package/dist/plugins/priority.json.js +1 -1
  33. package/dist/plugins/scim/baseResolver.js +1 -1
  34. package/dist/plugins/scim/errors.js +1 -1
  35. package/dist/plugins/scim/groups.js +1 -1
  36. package/dist/plugins/scim/list.js +1 -1
  37. package/dist/plugins/scim/scim.js +1 -1
  38. package/dist/plugins/scim/users.js +1 -1
  39. package/dist/plugins/storage/file.js +1 -0
  40. package/dist/plugins/storage/ldap.js +1 -0
  41. package/dist/plugins/storage.js +1 -0
  42. package/dist/plugins/twake/appAccountsApi.js +1 -1
  43. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  44. package/dist/plugins/twake/clouderyProvision.js +1 -1
  45. package/dist/plugins/twake/cozyProvision.js +1 -1
  46. package/dist/src/abstract/ldapFlat.d.ts +7 -0
  47. package/dist/src/abstract/plugin.d.ts +26 -0
  48. package/dist/src/bin/index.d.ts +50 -8
  49. package/dist/src/config/args.d.ts +36 -0
  50. package/dist/src/hooks.d.ts +70 -6
  51. package/dist/src/lib/auth/base.d.ts +175 -9
  52. package/dist/src/lib/authz/base.d.ts +159 -4
  53. package/dist/src/lib/authz/composition.d.ts +145 -0
  54. package/dist/src/lib/changeContext.d.ts +24 -0
  55. package/dist/src/lib/errors.d.ts +15 -0
  56. package/dist/src/lib/expressFormatedResponses.d.ts +9 -2
  57. package/dist/src/lib/ldapActions.d.ts +32 -1
  58. package/dist/src/lib/ldapCodes.d.ts +28 -0
  59. package/dist/src/lib/storage/store.d.ts +84 -0
  60. package/dist/src/lib/utils.d.ts +19 -0
  61. package/dist/src/plugins/auth/authzDynamic.d.ts +76 -0
  62. package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
  63. package/dist/src/plugins/auth/authzPerBranch.d.ts +46 -0
  64. package/dist/src/plugins/auth/authzPerRoute.d.ts +26 -0
  65. package/dist/src/plugins/auth/authzScope.d.ts +44 -3
  66. package/dist/src/plugins/auth/hmac.d.ts +2 -0
  67. package/dist/src/plugins/auth/llng.d.ts +39 -0
  68. package/dist/src/plugins/auth/openidconnect.d.ts +66 -3
  69. package/dist/src/plugins/auth/rateLimit.d.ts +3 -1
  70. package/dist/src/plugins/auth/token.d.ts +2 -0
  71. package/dist/src/plugins/auth/totp.d.ts +2 -0
  72. package/dist/src/plugins/bcl.d.ts +65 -0
  73. package/dist/src/plugins/ldap/groups.d.ts +7 -5
  74. package/dist/src/plugins/ldap/onChange.d.ts +17 -8
  75. package/dist/src/plugins/ldap/organizations.d.ts +92 -3
  76. package/dist/src/plugins/scim/baseResolver.d.ts +3 -1
  77. package/dist/src/plugins/scim/errors.d.ts +2 -10
  78. package/dist/src/plugins/storage/file.d.ts +19 -0
  79. package/dist/src/plugins/storage/ldap.d.ts +17 -0
  80. package/dist/src/plugins/storage.d.ts +44 -0
  81. package/docs/plugin-development/README.md +13 -19
  82. package/docs/plugin-development/dependencies.md +40 -15
  83. package/docs/plugin-development/hooks.md +6 -0
  84. package/docs/plugin-development/testing.md +1 -17
  85. package/docs/usage/configuration.md +90 -14
  86. package/docs/usage/plugins/README.md +17 -12
  87. package/docs/usage/plugins/auth/README.md +129 -0
  88. package/docs/usage/plugins/auth/authz-dynamic.md +42 -0
  89. package/docs/usage/plugins/auth/authz-linid1.md +33 -0
  90. package/docs/usage/plugins/auth/authz-per-branch.md +41 -11
  91. package/docs/usage/plugins/auth/authz-per-route.md +6 -0
  92. package/docs/usage/plugins/auth/authz-scope.md +74 -16
  93. package/docs/usage/plugins/auth/back-channel-logout.md +86 -0
  94. package/docs/usage/plugins/auth/llng.md +29 -0
  95. package/docs/usage/plugins/auth/oidc.md +52 -0
  96. package/docs/usage/plugins/ldap/on-change.md +51 -284
  97. package/docs/usage/plugins/ldap/organizations.md +37 -0
  98. package/docs/usage/plugins/utilities/storage.md +94 -0
  99. package/docs/usage/troubleshooting.md +1 -9
  100. package/docs/usage/upgrading.md +341 -0
  101. 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
+ }
@@ -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
- * 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;