IndieAuth login support, allows viewing of topics related to profile
[websub-hub] / src / session-manager.js
1 'use strict';
2
3 /**
4 * Here we process activities which support login sessions.
5 */
6
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');
12
13 const _fileScope = common.fileScope(__filename);
14
15 class SessionManager {
16 constructor(logger, authenticator, options) {
17 this.logger = logger;
18 this.authenticator = authenticator;
19 this.options = options;
20 this.indieAuthCommunication = new IndieAuthCommunication(logger, options);
21 this.mysteryBox = new MysteryBox(logger, options);
22
23 this.secureCookie = options.authenticator.secureAuthOnly ? ' Secure;' : '';
24 this.cookieLifespan = 60 * 60 * 24 * 32;
25 }
26
27
28 /**
29 * Set or update our session cookie.
30 * @param {http.ServerResponse} res
31 * @param {Object} session
32 * @param {Number} maxAge
33 */
34 async _sessionCookieSet(res, session, maxAge) {
35 const cookieName = 'WSHas';
36 const secureSession = session && await this.mysteryBox.pack(session) || '';
37 const cookie = [
38 `${cookieName}=${secureSession}`,
39 'HttpOnly',
40 this.secureCookie,
41 `Max-Age: ${maxAge}`,
42 ].join('; ');
43 res.setHeader(Enum.Header.SetCookie, cookie);
44 }
45
46
47 /**
48 * GET request for establishing admin session.
49 * @param {http.ServerResponse} res
50 * @param {Object} ctx
51 */
52 async getAdminLogin(res, ctx) {
53 const _scope = _fileScope('getAdminLogin');
54 this.logger.debug(_scope, 'called', { ctx });
55
56 res.end(Template.adminLoginHTML(ctx, this.options));
57 this.logger.info(_scope, 'finished', { ctx })
58 }
59
60
61 /**
62 * POST request for taking form data to establish admin session.
63 * @param {http.ServerResponse} res
64 * @param {Object} ctx
65 */
66 async postAdminLogin(res, ctx) {
67 const _scope = _fileScope('postAdminLogin');
68 this.logger.debug(_scope, 'called', { ctx });
69
70 ctx.errors = [];
71
72 // Only attempt user login if no IndieAuth profile is set
73 if (!ctx.parsedBody['me']) {
74 this.logger.debug(_scope, 'no indieauth profile, trying identifier', { ctx });
75
76 const identifier = ctx.parsedBody['identifier'];
77 const credential = ctx.parsedBody['credential'];
78
79 const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx);
80 if (!isValidLocalIdentifier) {
81 ctx.errors.push('Invalid username or password');
82 }
83
84 if (ctx.errors.length) {
85 res.end(Template.adminLoginHTML(ctx, this.options));
86 return;
87 }
88
89 // Valid auth, persist the authenticated session
90 ctx.session = {
91 authenticatedIdentifier: ctx.authenticationId,
92 };
93 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
94 res.statusCode = 302;
95 res.setHeader(Enum.Header.Location, './');
96 res.end();
97 this.logger.info(_scope, 'finished local', { ctx });
98 return;
99 }
100
101 let me, session, authorizationEndpoint;
102 try {
103 me = new URL(ctx.parsedBody['me']);
104 } catch (e) {
105 this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx });
106 ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`);
107 }
108
109 if (me) {
110 const profile = await this.indieAuthCommunication.fetchProfile(me);
111 if (!profile || !profile.authorizationEndpoint) {
112 this.logger.debug(_scope, 'failed to find any profile information at url', { ctx });
113 ctx.errors.push(`No profile information was found at '${me}'.`);
114 } else {
115 // fetch and parse me for 'authorization_endpoint' relation links
116 try {
117 authorizationEndpoint = new URL(profile.authorizationEndpoint);
118 } catch (e) {
119 ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
120 }
121 }
122
123 if (authorizationEndpoint) {
124 const pkce = await IndieAuthCommunication.generatePKCE();
125 session = {
126 authorizationEndpoint: authorizationEndpoint.href,
127 state: ctx.requestId,
128 codeVerifier: pkce.codeVerifier,
129 me,
130 };
131
132 Object.entries({
133 'response_type': 'code',
134 'client_id': this.options.dingus.selfBaseUrl,
135 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`,
136 'state': session.state,
137 'code_challenge': pkce.codeChallenge,
138 'code_challenge_method': pkce.codeChallengeMethod,
139 'me': me,
140 }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value));
141 }
142 }
143
144 if (ctx.errors.length) {
145 res.end(Template.adminLoginHTML(ctx, this.options));
146 return;
147 }
148
149 await this._sessionCookieSet(res, session, this.cookieLifespan);
150 res.setHeader(Enum.Header.Location, authorizationEndpoint.href);
151 res.statusCode = 302; // Found
152 res.end();
153
154 this.logger.info(_scope, 'finished indieauth', { ctx })
155 }
156
157
158 /**
159 * GET request to remove current credentials.
160 * @param {http.ServerResponse} res
161 * @param {Object} ctx
162 */
163 async getAdminLogout(res, ctx) {
164 const _scope = _fileScope('getAdminLogout');
165 this.logger.debug(_scope, 'called', { ctx });
166
167 this._sessionCookieSet(res, '', 0);
168 res.statusCode = 302;
169 res.setHeader(Enum.Header.Location, './');
170 res.end();
171
172 this.logger.info(_scope, 'finished', { ctx });
173 }
174
175
176 /**
177 * GET request for returning IndieAuth redirect.
178 * @param {http.ServerResponse} res
179 * @param {Object} ctx
180 */
181 async getAdminIA(res, ctx) {
182 const _scope = _fileScope('getAdminIA');
183 this.logger.debug(_scope, 'called', { ctx });
184
185 ctx.errors = [];
186 ctx.session = {};
187
188 // Unpack cookie to restore session data
189
190 const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', '');
191 if (cookieName !== 'WSHas') {
192 this.logger.debug(_scope, 'no cookie', { ctx });
193 ctx.errors.push('missing required cookie');
194 } else {
195 try {
196 ctx.session = await this.mysteryBox.unpack(cookieValue);
197 this.logger.debug(_scope, 'restored session from cookie', { ctx });
198 } catch (e) {
199 this.logger.debug(_scope, 'could not unpack cookie');
200 ctx.errors.push('invalid cookie');
201 }
202 }
203
204 // Validate unpacked session values
205
206 // Add any auth errors
207 if (ctx.queryParams['error']) {
208 ctx.errors.push(ctx.queryParams['error']);
209 if (ctx.queryParams['error_description']) {
210 ctx.errors.push(ctx.queryParams['error_description']);
211 }
212 }
213
214 // check stuff
215 if (ctx.queryParams['state'] !== ctx.session.state) {
216 this.logger.debug(_scope, 'state mismatch', { ctx });
217 ctx.errors.push('invalid state');
218 }
219
220 const code = ctx.queryParams['code'];
221 if (!code) {
222 this.logger.debug(_scope, 'missing code', { ctx });
223 ctx.errors.push('invalid code');
224 }
225
226 let redeemProfileUrl;
227 try {
228 redeemProfileUrl = new URL(ctx.session.authorizationEndpoint);
229 } catch (e) {
230 this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx });
231 ctx.errors.push('invalid cookie');
232 }
233 let profile;
234 if (redeemProfileUrl) {
235 profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`);
236 if (!profile) {
237 this.logger.debug(_scope, 'no profile from code redemption', { ctx });
238 ctx.errors.push('did not get a profile response from authorization endpoint code redemption');
239 } else if (!profile.me) {
240 this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx });
241 ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption');
242 } else if (profile.me !== ctx.session.me) {
243 this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile });
244 const newProfileUrl = new URL(profile.me);
245 // Rediscover auth endpoint for the new returned profile.
246 const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl);
247 if (newProfile.authorizationEndpoint !== ctx.session.authorizationEndpoint) {
248 this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile });
249 ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue');
250 } else {
251 // The endpoints match, all is okay, update our records.
252 ctx.session.me = profile.me;
253 }
254 }
255 }
256
257 if (ctx.errors.length) {
258 await this._sessionCookieSet(res, '', 0);
259 res.end(Template.adminIAHTML(ctx, this.options));
260 return;
261 }
262
263 // set cookie as auth valid, redirect to admin
264 ctx.session = {
265 authenticatedProfile: ctx.session.me,
266 };
267
268 await this._sessionCookieSet(res, ctx.session, this.cookieLifespan);
269 res.statusCode = 302;
270 res.setHeader(Enum.Header.Location, './');
271 res.end();
272
273 this.logger.info(_scope, 'finished', { ctx })
274 }
275
276
277 }
278
279 module.exports = SessionManager;