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.
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/bcl.js +1 -1
- package/dist/src/hooks.d.ts +12 -3
- package/dist/src/plugins/auth/openidconnect.d.ts +47 -0
- package/dist/src/plugins/bcl.d.ts +2 -1
- package/docs/usage/plugins/auth/back-channel-logout.md +28 -0
- package/docs/usage/plugins/auth/oidc.md +8 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"express-openid-connect";import
|
|
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};
|
package/dist/plugins/bcl.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import
|
|
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};
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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:
|