4 * Here we process activities which support login sessions.
7 const { Communication: IndieAuthCommunication
} = require('@squeep/indieauth-helper');
8 const { MysteryBox
} = require('@squeep/mystery-box');
9 const common
= require('./common');
10 const Enum
= require('./enum');
11 const Template
= require('./template');
13 const _fileScope
= common
.fileScope(__filename
);
15 class SessionManager
{
17 * @param {Console} logger
18 * @param {Authenticator} authenticator
19 * @param {Object} options
20 * @param {Object} options.authenticator
21 * @param {String[]} options.authenticator.authnEnabled
22 * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds
23 * @param {Boolean} options.authenticator.secureAuthOnly
24 * @param {Object} options.dingus
25 * @param {Object} options.dingus.proxyPrefix
26 * @param {Object} options.dingus.selfBaseUrl
28 constructor(logger
, authenticator
, options
) {
30 this.authenticator
= authenticator
;
31 this.options
= options
;
32 this.indieAuthCommunication
= new IndieAuthCommunication(logger
, options
);
33 this.mysteryBox
= new MysteryBox(options
);
34 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
36 this.cookieLifespan
= options
.authenticator
.inactiveSessionLifespanSeconds
|| 60 * 60 * 24 * 32;
41 * Set or update our session cookie.
42 * @param {http.ServerResponse} res
43 * @param {Object} session
44 * @param {Number} maxAge
45 * @param {String} path
47 async
_sessionCookieSet(res
, session
, maxAge
, path
= '/') {
48 const _scope
= _fileScope('_sessionCookieSet');
50 const cookieName
= Enum
.SessionCookie
;
51 const secureSession
= session
&& await
this.mysteryBox
.pack(session
) || '';
53 `${cookieName}=${secureSession}`,
57 if (this.options
.authenticator
.secureAuthOnly
) {
58 cookieParts
.push('Secure');
60 if (typeof(maxAge
) === 'number') {
61 cookieParts
.push(`Max-Age=${maxAge}`);
64 cookieParts
.push(`Path=${this.options.dingus.proxyPrefix}${path}`);
66 const cookie
= cookieParts
.join('; ');
67 this.logger
.debug(_scope
, 'session cookie', { cookie
, session
})
68 res
.setHeader(Enum
.Header
.SetCookie
, cookie
);
73 * GET request for establishing admin session.
74 * @param {http.ServerResponse} res
77 async
getAdminLogin(res
, ctx
) {
78 const _scope
= _fileScope('getAdminLogin');
79 this.logger
.debug(_scope
, 'called', { ctx
});
81 res
.end(Template
.LoginHTML(ctx
, this.options
));
82 this.logger
.info(_scope
, 'finished', { ctx
})
87 * POST request for taking form data to establish admin session.
88 * @param {http.ServerResponse} res
91 async
postAdminLogin(res
, ctx
) {
92 const _scope
= _fileScope('postAdminLogin');
93 this.logger
.debug(_scope
, 'called', { ctx
});
97 const redirect
= ctx
.queryParams
['r'] || './';
99 // Only attempt user login if no IndieAuth profile is set
100 if (!this.options
.authenticator
.authnEnabled
.includes('indieAuth') || !ctx
.parsedBody
['me']) {
102 const identifier
= ctx
.parsedBody
['identifier'];
103 const credential
= ctx
.parsedBody
['credential']; // N.B. Logger must specifically mask this field from ctx.
105 const isValidLocalIdentifier
= await
this.authenticator
.isValidIdentifierCredential(identifier
, credential
, ctx
);
106 if (!isValidLocalIdentifier
) {
107 ctx
.errors
.push('Invalid username or password');
110 if (ctx
.errors
.length
) {
111 res
.end(Template
.LoginHTML(ctx
, this.options
));
115 // Valid auth, persist the authenticated session
117 authenticatedIdentifier: ctx
.authenticationId
,
119 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
120 res
.statusCode
= 302;
121 res
.setHeader(Enum
.Header
.Location
, redirect
);
123 this.logger
.info(_scope
, 'finished local', { ctx
});
127 // Otherwise, carry on with IndieAuth handshake.
128 let me
, meAutoScheme
, session
, authorizationEndpoint
;
130 me
= new URL(ctx
.parsedBody
['me']);
131 meAutoScheme
= !!ctx
.parsedBody
['me_auto_scheme'];
134 this.logger
.debug(_scope
, 'failed to parse supplied profile url', { ctx
});
135 ctx
.errors
.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
138 if (this.options
.authenticator
.authnEnabled
.includes('indieAuth')
141 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
142 if ((!profile
|| !profile
.metadata
)
144 this.logger
.debug(_scope
, 'trying http fallback', { ctx
});
145 me
.protocol
= 'http';
146 profile
= await
this.indieAuthCommunication
.fetchProfile(me
);
148 if (!profile
|| !profile
.metadata
) {
149 this.logger
.debug(_scope
, 'failed to find any profile information at url', { ctx
});
150 ctx
.errors
.push(`No profile information was found at '${me}'.`);
152 // fetch and parse me for 'authorization_endpoint' relation links
154 authorizationEndpoint
= new URL(profile
.metadata
.authorizationEndpoint
);
156 ctx
.errors
.push(`Unable to understand the authorization endpoint ('${profile.metadata.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
159 if (profile
.metadata
.issuer
) {
162 const issuer
= new URL(profile
.metadata
.issuer
);
165 || issuer
.protocol
.toLowerCase() !== 'https:') { // stupid URL trailing colon thing
166 this.logger
.debug(_scope
, 'supplied issuer url invalid', { ctx
});
167 ctx
.errors
.push('Authorization server provided an invalid issuer field.');
170 this.logger
.debug(_scope
, 'failed to parse supplied issuer url', { ctx
});
171 ctx
.errors
.push('Authorization server provided an unparsable issuer field.');
174 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
175 // Strict 20220212 compliance would error here.
176 // ctx.errors.push('Authorization server did not provide issuer field, as required by current specification.');
180 if (authorizationEndpoint
) {
181 const pkce
= await IndieAuthCommunication
.generatePKCE();
184 authorizationEndpoint: authorizationEndpoint
.href
,
185 state: ctx
.requestId
,
186 codeVerifier: pkce
.codeVerifier
,
189 issuer: profile
.metadata
.issuer
,
192 // Update auth endpoint parameters
194 'response_type': 'code',
195 'client_id': this.options
.dingus
.selfBaseUrl
,
196 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
197 'state': session
.state
,
198 'code_challenge': pkce
.codeChallenge
,
199 'code_challenge_method': pkce
.codeChallengeMethod
,
201 }).forEach(([name
, value
]) => authorizationEndpoint
.searchParams
.set(name
, value
));
205 if (ctx
.errors
.length
) {
206 res
.end(Template
.LoginHTML(ctx
, this.options
));
210 await
this._sessionCookieSet(res
, session
, this.cookieLifespan
);
211 res
.setHeader(Enum
.Header
.Location
, authorizationEndpoint
.href
);
212 res
.statusCode
= 302; // Found
215 this.logger
.info(_scope
, 'finished indieauth', { ctx
})
220 * GET request to remove current credentials.
221 * @param {http.ServerResponse} res
222 * @param {Object} ctx
224 async
getAdminLogout(res
, ctx
) {
225 const _scope
= _fileScope('getAdminLogout');
226 this.logger
.debug(_scope
, 'called', { ctx
});
228 this._sessionCookieSet(res
, '', 0);
230 const redirect
= ctx
.queryParams
['r'] || './';
232 res
.statusCode
= 302;
233 res
.setHeader(Enum
.Header
.Location
, redirect
);
236 this.logger
.info(_scope
, 'finished', { ctx
});
241 * GET request for returning IndieAuth redirect.
242 * This currently only redeems a scope-less profile.
243 * @param {http.ServerResponse} res
244 * @param {Object} ctx
246 async
getAdminIA(res
, ctx
) {
247 const _scope
= _fileScope('getAdminIA');
248 this.logger
.debug(_scope
, 'called', { ctx
});
253 // Unpack cookie to restore session data
255 const [ cookieName
, cookieValue
] = common
.splitFirst((ctx
.cookie
|| ''), '=', '');
256 if (cookieName
!== Enum
.SessionCookie
) {
257 this.logger
.debug(_scope
, 'no cookie', { ctx
});
258 ctx
.errors
.push('missing required cookie');
261 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
262 this.logger
.debug(_scope
, 'restored session from cookie', { ctx
});
264 this.logger
.debug(_scope
, 'could not unpack cookie');
265 ctx
.errors
.push('invalid cookie');
269 // Validate unpacked session values
272 // Add any auth errors
273 if (ctx
.queryParams
['error']) {
274 ctx
.errors
.push(ctx
.queryParams
['error']);
275 if (ctx
.queryParams
['error_description']) {
276 ctx
.errors
.push(ctx
.queryParams
['error_description']);
281 if (ctx
.queryParams
['state'] !== ctx
.session
.state
) {
282 this.logger
.debug(_scope
, 'state mismatch', { ctx
});
283 ctx
.errors
.push('invalid state');
286 const code
= ctx
.queryParams
['code'];
288 this.logger
.debug(_scope
, 'missing code', { ctx
});
289 ctx
.errors
.push('invalid code');
293 if (ctx
.session
.issuer
) {
294 if (ctx
.queryParams
['iss'] !== ctx
.session
.issuer
) {
295 this.logger
.debug(_scope
, 'issuer mismatch', { ctx
});
296 ctx
.errors
.push('invalid issuer');
299 this.logger
.debug(_scope
, 'no issuer in metadata, assuming legacy mode', { ctx
});
300 // Strict 20220212 compliance would error here. (Also earlier.)
301 // ctx.errors.push('invalid issuer');
304 let redeemProfileUrl
;
306 redeemProfileUrl
= new URL(ctx
.session
.authorizationEndpoint
);
308 this.logger
.debug(_scope
, 'failed to parse restored session authorization endpoint as url', { ctx
});
309 ctx
.errors
.push('invalid cookie');
312 if (redeemProfileUrl
) {
313 profile
= await
this.indieAuthCommunication
.redeemProfileCode(redeemProfileUrl
, code
, ctx
.session
.codeVerifier
, this.options
.dingus
.selfBaseUrl
, `${this.options.dingus.selfBaseUrl}admin/_ia`);
315 this.logger
.debug(_scope
, 'no profile from code redemption', { ctx
});
316 ctx
.errors
.push('did not get a profile response from authorization endpoint code redemption');
317 } else if (!profile
.me
) {
318 this.logger
.debug(_scope
, 'no profile me identifier from code redemption', { ctx
});
319 ctx
.errors
.push('did not get \'me\' value from authorization endpoint code redemption');
320 } else if (profile
.me
!== ctx
.session
.me
) {
321 this.logger
.debug(_scope
, 'mis-matched canonical me from redeemed profile', { ctx
, profile
});
322 const newProfileUrl
= new URL(profile
.me
);
323 // Rediscover auth endpoint for the new returned profile.
324 const newProfile
= await
this.indieAuthCommunication
.fetchProfile(newProfileUrl
);
325 if (newProfile
.metadata
.authorizationEndpoint
!== ctx
.session
.authorizationEndpoint
) {
326 this.logger
.debug(_scope
, 'mis-matched auth endpoints between provided me and canonical me', { ctx
, profile
, newProfile
});
327 ctx
.errors
.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
329 // The endpoints match, all is okay, update our records.
330 ctx
.session
.me
= profile
.me
;
335 if (ctx
.errors
.length
) {
336 await
this._sessionCookieSet(res
, '', 0);
337 res
.end(Template
.IAHTML(ctx
, this.options
));
341 const redirect
= ctx
.session
.redirect
|| './';
343 // Set cookie as auth valid, redirect to original location.
345 authenticatedProfile: ctx
.session
.me
,
348 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
349 res
.statusCode
= 302;
350 res
.setHeader(Enum
.Header
.Location
, redirect
);
353 this.logger
.info(_scope
, 'finished', { ctx
})
359 module
.exports
= SessionManager
;