ldap-rest 1.0.0 → 1.0.1

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.
@@ -1 +1 @@
1
- import e from"express-openid-connect";import o from"../../lib/auth/base.js";import{launchHooksChained as i}from"../../lib/utils.js";import{serverError as t,unauthorized as s}from"../../lib/expressFormatedResponses.js";const{auth:n,requiresAuth:r}=e,c=["/login","/logout","/callback","/backchannel-logout"],a=/^end_session_endpoint must be configured/,h=/^\/logout\/?$/i,walkSubscribers=async(e,o)=>{let i;for(const t of e??[])if(t)try{await t(o)}catch(e){i??(i=e instanceof Error?e:new Error(String(e)))}if(i)throw i};class OpenIDConnect extends o{constructor(e){super(e),this.name="openidconnect",this.configApiKey="openidconnect",this.roles=["auth","configurable"],this.warnedNoEndSession=!1;for(const e of["oidc_server","oidc_client_id","oidc_client_secret","base_url"])if(!this.config[e])throw new Error(`Missing config parameter ${e}`)}buildConfig(){return{authRequired:!1,issuerBaseURL:this.config.oidc_server,clientID:this.config.oidc_client_id,secret:this.config.oidc_client_secret,baseURL:this.config.base_url,clientSecret:this.config.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},backchannelLogout:{onLogoutToken:async e=>{this.server.hooks.oidclogouttoken?await walkSubscribers(this.server.hooks.oidclogouttoken,e):this.logger.warn("openidconnect: a logout token arrived but no Back-Channel Logout plugin is loaded, so nothing records it")},onLogin:async e=>{if(!this.server.hooks.oidclogin)return;const o=e.oidc?.idTokenClaims;if(o)try{await walkSubscribers(this.server.hooks.oidclogin,o)}catch(o){const i=e.appSession;if(i)for(const e of Object.keys(i))delete i[e];throw o}},isLoggedOut:async e=>{if(!this.server.hooks.oidcsessionvalid)return!1;const o=e.oidc.idTokenClaims,[,t]=await i(this.server.hooks.oidcsessionvalid,[o,!0]);return!t}}}}identitySource(){const e=this.config.oidc_username_claim||"sub";return`req.user: the OIDC sub, an opaque provider identifier; req.userName: the "${e}" claim`+("sub"===e?" — the same value, until --oidc-username-claim names another":"")}getConfigApiData(){return{enabled:!0,endpoints:{logout:"/logout"}}}get pathPrefixes(){const e=super.pathPrefixes;return 0===e.length?e:[...e,...c]}api(e){this.router=n(this.buildConfig()),super.api(e)}authMethod(e,o,i){if(!this.router)return t(o,new Error(`${this.name}: api() has not built the router`));this.router(e,o,n=>{if(n&&h.test(e.path)&&this.noEndSession(n))return o.redirect(this.config.base_url);if(n)return t(o,n);if(("GET"===e.method||"HEAD"===e.method)&&h.test(e.path))return void o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}});const guard=()=>{r()(e,o,t=>{if(t)return s(o);const n=e.oidc.user,r=this.config.oidc_username_claim||"sub",c=n[r];"sub"!==r&&"string"!=typeof c&&this.logger.warn(`${this.name}: no "${r}" claim on this session, so req.userName falls back to the sub. Check --oidc-username-claim against the scopes the provider is asked for`),this.publishIdentity(e,String(n.sub),"string"==typeof c?c:String(n.sub)),i()})},c=this.renewExpired(e);c?c.then(guard):guard()})}renewExpired(e){const o=e.oidc,i=o.accessToken;if(!i?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return i.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}noEndSession(e){return!!(e instanceof Error&&a.test(e.message))&&(this.warnedNoEndSession||(this.warnedNoEndSession=!0,this.logger.warn(`${this.name}: the provider publishes no end_session_endpoint, so /logout ends the session here only, and the provider may log the caller back in`)),!0)}}export{OpenIDConnect as default};
1
+ import{randomUUID as e}from"crypto";import o from"express-openid-connect";import s from"../../lib/auth/base.js";import{launchHooksChained as t}from"../../lib/utils.js";import{serverError as i,unauthorized as n}from"../../lib/expressFormatedResponses.js";const{auth:r,requiresAuth:a}=o,c=["/login","/logout","/callback","/backchannel-logout"],d=/^end_session_endpoint must be configured/,h=/^\/logout\/?$/i,u="dmSession",sessionClaims=e=>{const{oidc:o,appSession:s}=e,t=o?.idTokenClaims;if(!t)return;const i=s?.[u];return"string"==typeof i?{...t,session:i}:t},subjectOf=e=>{try{const o=JSON.parse(Buffer.from(String(e).split(".")[1],"base64url").toString());return"string"==typeof o.sub?o.sub:void 0}catch{return}},walkSubscribers=async(e,o)=>{let s;for(const t of e??[])if(t)try{await t(o)}catch(e){s??(s=e instanceof Error?e:new Error(String(e)))}if(s)throw s};class OpenIDConnect extends s{constructor(e){super(e),this.name="openidconnect",this.configApiKey="openidconnect",this.roles=["auth","configurable"],this.warnedNoEndSession=!1,this.warnedNoRecord=!1,this.warnedUnnamed=!1;for(const e of["oidc_server","oidc_client_id","oidc_client_secret","base_url"])if(!this.config[e])throw new Error(`Missing config parameter ${e}`)}buildConfig(){return{authRequired:!1,issuerBaseURL:this.config.oidc_server,clientID:this.config.oidc_client_id,secret:this.config.oidc_client_secret,baseURL:this.config.base_url,clientSecret:this.config.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},afterCallback:async(e,o,s)=>({...s,[u]:await this.sessionId(e,subjectOf(s.id_token))}),backchannelLogout:{onLogoutToken:async e=>{this.server.hooks.oidclogouttoken?await walkSubscribers(this.server.hooks.oidclogouttoken,e):this.logger.warn("openidconnect: a logout token arrived but no Back-Channel Logout plugin is loaded, so nothing records it")},onLogin:async e=>{if(!this.server.hooks.oidclogin)return;const o=e.oidc?.idTokenClaims;if(o)try{await walkSubscribers(this.server.hooks.oidclogin,o)}catch(o){const s=e.appSession;if(s)for(const e of Object.keys(s))delete s[e];throw o}},isLoggedOut:e=>this.loggedOut(e)}}}identitySource(){const e=this.config.oidc_username_claim||"sub";return`req.user: the OIDC sub, an opaque provider identifier; req.userName: the "${e}" claim`+("sub"===e?" — the same value, until --oidc-username-claim names another":"")}getConfigApiData(){return{enabled:!0,endpoints:{logout:"/logout"}}}get pathPrefixes(){const e=super.pathPrefixes;return 0===e.length?e:[...e,...c]}api(e){this.router=r(this.buildConfig()),super.api(e)}authMethod(e,o,s){if(!this.router)return i(o,new Error(`${this.name}: api() has not built the router`));this.router(e,o,t=>{if(t&&h.test(e.path)&&this.noEndSession(t))return o.redirect(this.config.base_url);if(t)return i(o,t);if(("GET"===e.method||"HEAD"===e.method)&&h.test(e.path))return void this.endHere(e).then(()=>o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}}));const guard=()=>{a()(e,o,t=>{if(t)return n(o);const i=e.oidc.user,r=this.config.oidc_username_claim||"sub",a=i[r];"sub"!==r&&"string"!=typeof a&&this.logger.warn(`${this.name}: no "${r}" claim on this session, so req.userName falls back to the sub. Check --oidc-username-claim against the scopes the provider is asked for`),this.publishIdentity(e,String(i.sub),"string"==typeof a?a:String(i.sub)),s()})},r=this.renewExpired(e);r?r.then(guard):guard()})}renewExpired(e){const o=e.oidc,s=o.accessToken;if(!s?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return s.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}async loggedOut(e){if(!this.server.hooks.oidcsessionvalid)return!1;const o=sessionClaims(e),[,s]=await t(this.server.hooks.oidcsessionvalid,[o,!0]);return!s}async sessionId(o,s){const{oidc:t,appSession:i}=o,n=i?.[u];return"string"==typeof n&&void 0!==s&&t?.isAuthenticated()&&t.idTokenClaims?.sub===s&&!await this.loggedOut(o).catch(()=>!0)?n:e()}async endHere(e){if(!e.oidc?.isAuthenticated())return;const o=sessionClaims(e);if(!o)return;const{iss:s,sid:t,session:i}=o,n=i?{iss:s,session:i}:t?{iss:s,sid:t}:void 0;if(n)if(this.server.hooks.oidclogouttoken)try{await walkSubscribers(this.server.hooks.oidclogouttoken,n)}catch(e){this.logger.error(`${this.name}: /logout could not record the session it ends, so a copy of its cookie stays valid until its tokens expire: ${String(e)}`)}else this.warnedNoRecord||(this.warnedNoRecord=!0,this.logger.warn(`${this.name}: no Back-Channel Logout plugin is loaded, so /logout drops the browser's cookie and nothing else: a copy of it stays valid until its tokens expire. Load core/bcl to end the session here as well`));else this.warnedUnnamed||(this.warnedUnnamed=!0,this.logger.warn(`${this.name}: /logout ended a session nothing names — one established before this version, from a provider that sends no sid — so a copy of its cookie stays valid until its tokens expire`))}noEndSession(e){return!!(e instanceof Error&&d.test(e.message))&&(this.warnedNoEndSession||(this.warnedNoEndSession=!0,this.logger.warn(`${this.name}: the provider publishes no end_session_endpoint, so /logout ends the session here only, and the provider may log the caller back in`)),!0)}}export{OpenIDConnect as default};
@@ -1 +1 @@
1
- import t from"../abstract/plugin.js";const e="bcl";class Bcl extends t{constructor(t){super(t),this.name="bcl",this.dependencies={storage:"core/storage"},this.hooks={oidclogouttoken:async t=>{const s=Bcl.keys(t);if(0===s.length)return void this.logger.warn(`${this.name}: a logout token carried no sid nor sub`);const o=Date.now()+this.retention;await Promise.all(s.map(t=>this.store.set(e,t,"1",o)))},oidclogin:async t=>{await Promise.all(Bcl.keys(t).map(t=>this.store.delete(e,t)))},oidcsessionvalid:async([t,s])=>{if(!s)return[t,s];for(const s of Bcl.keys(t))if(null!==await this.store.get(e,s))return[t,!1];return[t,!0]}},this.retention=1e3*(t.config.bcl_retention||604800)}api(){const t=this.requirePlugin("storage");if(!t)throw new Error("bcl: needs core/storage loaded, and --storage-backend set. Without somewhere to keep what a logout killed, every session would outlive the logout that closed it and nothing would say so.");this.storage=t}get store(){if(!this.storage)throw new Error("bcl: the store was asked for before api() took hold of it. A plugin built outside registerPlugin has to call api() as it does.");return this.storage.store}static keys(t){const e=[];return t.sid&&e.push(`${t.iss}|sid|${t.sid}`),t.sub&&e.push(`${t.iss}|sub|${t.sub}`),e}}export{Bcl as default};
1
+ import s from"../abstract/plugin.js";const t="bcl";class Bcl extends s{constructor(s){super(s),this.name="bcl",this.dependencies={storage:"core/storage"},this.hooks={oidclogouttoken:async s=>{const e=Bcl.keys(s);if(0===e.length)return void this.logger.warn(`${this.name}: a logout token carried no sid nor sub`);const o=Date.now()+this.retention;await Promise.all(e.map(s=>this.store.set(t,s,"1",o)))},oidclogin:async s=>{await Promise.all(Bcl.keys(s).map(s=>this.store.delete(t,s)))},oidcsessionvalid:async([s,e])=>{if(!e)return[s,e];for(const e of Bcl.keys(s))if(null!==await this.store.get(t,e))return[s,!1];return[s,!0]}},this.retention=1e3*(s.config.bcl_retention||604800)}api(){const s=this.requirePlugin("storage");if(!s)throw new Error("bcl: needs core/storage loaded, and --storage-backend set. Without somewhere to keep what a logout killed, every session would outlive the logout that closed it and nothing would say so.");this.storage=s}get store(){if(!this.storage)throw new Error("bcl: the store was asked for before api() took hold of it. A plugin built outside registerPlugin has to call api() as it does.");return this.storage.store}static keys(s){const t=[];return s.sid&&t.push(`${s.iss}|sid|${s.sid}`),s.sub&&t.push(`${s.iss}|sub|${s.sub}`),s.session&&t.push(`${s.iss}|session|${s.session}`),t}}export{Bcl as default};
@@ -13,13 +13,19 @@ export type ChainedHook<T> = (arg: T) => MaybePromise<T>;
13
13
  /**
14
14
  * What identifies an OpenID Connect session on both sides of a Back-Channel
15
15
  * Logout: the issuer, plus whichever of `sid` and `sub` the provider sends.
16
- * At least one of the two is present — the specification requires it, and
17
- * `express-openid-connect` refuses the token otherwise.
16
+ * At least one of the two is present in a logout token — the specification
17
+ * requires it, and `express-openid-connect` refuses the token otherwise.
18
+ *
19
+ * `session` is the id `core/auth/openidconnect` gave the session at login and
20
+ * keeps in its cookie. `/logout` ends a session by it: a provider that sends
21
+ * no `sid` leaves nothing else naming that one session, and a mark on the
22
+ * `sub` would end every session of that person.
18
23
  */
19
24
  export interface OidcSessionClaims {
20
25
  iss: string;
21
26
  sid?: string;
22
27
  sub?: string;
28
+ session?: string;
23
29
  }
24
30
  export type OidcLogoutToken = OidcSessionClaims;
25
31
  export type VoidHook<T extends unknown[]> = (...args: T) => MaybePromise<void>;
@@ -95,7 +101,10 @@ export interface Hooks {
95
101
  * already given is never turned back into an acceptance.
96
102
  */
97
103
  oidcsessionvalid?: ChainedHook<[OidcSessionClaims, boolean]>;
98
- /** A logout token arrived and verified; subscribers record what it kills. */
104
+ /**
105
+ * A logout token arrived and verified, or `/logout` ended a session here;
106
+ * subscribers record what it kills.
107
+ */
99
108
  oidclogouttoken?: VoidHook<[OidcLogoutToken]>;
100
109
  /**
101
110
  * A session was just established; subscribers forget what would kill it.
@@ -11,6 +11,10 @@ export default class OpenIDConnect extends AuthBase {
11
11
  private router?;
12
12
  /** Set once the missing `end_session_endpoint` has been reported. */
13
13
  private warnedNoEndSession;
14
+ /** Set once a `/logout` with nothing to record it has been reported. */
15
+ private warnedNoRecord;
16
+ /** Set once a `/logout` of a session nothing names has been reported. */
17
+ private warnedUnnamed;
14
18
  constructor(server: DM);
15
19
  /**
16
20
  * What is handed to `express-openid-connect`.
@@ -99,6 +103,49 @@ export default class OpenIDConnect extends AuthBase {
99
103
  * one ends the session. Undefined when there is nothing to wait for.
100
104
  */
101
105
  private renewExpired;
106
+ /**
107
+ * Whether the session in front of a request was ended — by a logout token,
108
+ * or by `/logout`.
109
+ *
110
+ * @param req the request, after the library has read its session
111
+ * @returns true when a subscriber of `oidcsessionvalid` refuses it
112
+ */
113
+ private loggedOut;
114
+ /**
115
+ * The id a session gets at login.
116
+ *
117
+ * The same person logging in again over a live session keeps its id: the
118
+ * library merges the new tokens into that session, and a copy of its
119
+ * cookie taken before would otherwise carry an id the next `/logout` no
120
+ * longer names. Anyone else, or a session already ended — the callback
121
+ * runs before the library asks — gets a new one.
122
+ *
123
+ * @param req the callback request, its session as the cookie brought it
124
+ * @param sub the `sub` of the id token just received
125
+ * @returns the id to keep in the session
126
+ */
127
+ private sessionId;
128
+ /**
129
+ * Record that the session in front of `/logout` is dead, as a logout token
130
+ * naming it would.
131
+ *
132
+ * Dropping the cookie ends the browser's copy only: the cookie holds the
133
+ * whole session, and a copy kept elsewhere — a shared computer, a backup
134
+ * of the browser profile, a stolen cookie — stayed valid until its tokens
135
+ * expired. The provider ends the sessions of the other applications
136
+ * through Back-Channel Logout, but does not tell the one that asked it to.
137
+ *
138
+ * The session is named by the id given at login, or by the provider's
139
+ * `sid` for a session from before that id existed — never by the `sub`,
140
+ * which would end every session of that person.
141
+ *
142
+ * A failure to record is logged and the logout goes on: the caller asked
143
+ * to leave, and refusing would keep the browser's copy alive as well.
144
+ *
145
+ * @param req the request, after the library's router
146
+ * @returns once the subscribers have run; never rejects
147
+ */
148
+ private endHere;
102
149
  /**
103
150
  * Whether `/logout` failed because the provider offers no RP-Initiated
104
151
  * Logout, rather than for a reason worth a 500.
@@ -54,7 +54,8 @@ export default class Bcl extends DmPlugin {
54
54
  * The keys a token kills.
55
55
  *
56
56
  * A provider sends `sid`, or `sub`, or both: `sid` ends one session, `sub`
57
- * ends every session of that person.
57
+ * ends every session of that person. `/logout` sends `session`, the id
58
+ * this server gave the one session it ends.
58
59
  */
59
60
  private static keys;
60
61
  hooks: {
@@ -18,6 +18,29 @@ saying that one `sid`, or every session of one `sub`, is dead. Sessions stay
18
18
  in their cookie, and the storage holds only what died — which is what keeps
19
19
  it small.
20
20
 
21
+ ## The Server's Own `/logout`
22
+
23
+ A provider sends logout tokens to the other applications, not to the one
24
+ that asked it to log out. So `core/auth/openidconnect` records the session
25
+ its own `/logout` ends, as a logout token naming it would: by the id it gave
26
+ that session at login, kept in the cookie, which ends that one session and
27
+ leaves the person's others alone. The same person logging in again over a
28
+ live session keeps its id, so a copy taken before is ended with it.
29
+
30
+ A session from before that id existed is named by the provider's `sid`, when
31
+ it sent one. A provider that sends none (Dex) leaves such a session
32
+ unnamed: its copies keep working until its tokens expire, and the first
33
+ `/logout` of one logs a warning. That lasts until those sessions have
34
+ expired, a week at most.
35
+
36
+ Without `core/bcl`, `/logout` drops the browser's cookie and nothing else,
37
+ and a copy of it keeps working until its tokens expire.
38
+
39
+ A plugin subscribing to `oidclogouttoken` receives `{ iss, session }` from
40
+ `/logout`, with neither `sid` nor `sub`: `session` names one session of this
41
+ server, and is matched against the `session` that `oidcsessionvalid`
42
+ carries.
43
+
21
44
  ## Configuration
22
45
 
23
46
  ```bash
@@ -45,6 +68,11 @@ A tombstone has to outlive the session it kills, or a cookie older than the
45
68
  mark would be honoured again. The default matches `express-openid-connect`'s
46
69
  own seven-day session; shorten it only alongside the session lifetime.
47
70
 
71
+ A copy of a session `/logout` ended is refused, so no longer renewed: it
72
+ dies a day after its last use at the latest, the cookie's rolling lifetime.
73
+ While the store cannot answer, though, a copy is still renewed, up to the
74
+ week; keep the retention at the session lifetime.
75
+
48
76
  Expiry is enforced when the mark is read, not only when the sweeper passes,
49
77
  so a late sweeper never keeps out someone who has since logged in again.
50
78
 
@@ -121,6 +121,14 @@ A provider whose discovery document names no `end_session_endpoint` gets a
121
121
  local logout — the cookie is dropped, the browser goes to `--base-url` — and
122
122
  a warning saying the provider may log the caller back in.
123
123
 
124
+ The session itself ends here only with [`core/bcl`](back-channel-logout.md)
125
+ loaded. The cookie holds the whole session, so dropping it ends the
126
+ browser's copy: a copy kept elsewhere — a shared computer, a backup of the
127
+ browser profile, a stolen cookie — keeps working until its tokens expire.
128
+ With `core/bcl`, `/logout` records the session it ends, named by an id given
129
+ at login, and that copy is refused. Without it, the first `/logout` logs a
130
+ warning saying so.
131
+
124
132
  `GET /v1/config` (with `core/configApi`) publishes the route, so a front-end
125
133
  knows it can offer a sign-out control. The key is `openidconnect` whatever
126
134
  the instance is named:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"