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
{
16 constructor(logger
, authenticator
, options
) {
18 this.authenticator
= authenticator
;
19 this.options
= options
;
20 this.indieAuthCommunication
= new IndieAuthCommunication(logger
, options
);
21 this.mysteryBox
= new MysteryBox(logger
, options
);
23 this.secureCookie
= options
.authenticator
.secureAuthOnly
? ' Secure;' : '';
24 this.cookieLifespan
= 60 * 60 * 24 * 32;
29 * Set or update our session cookie.
30 * @param {http.ServerResponse} res
31 * @param {Object} session
32 * @param {Number} maxAge
34 async
_sessionCookieSet(res
, session
, maxAge
) {
35 const cookieName
= 'WSHas';
36 const secureSession
= session
&& await
this.mysteryBox
.pack(session
) || '';
38 `${cookieName}=${secureSession}`,
43 res
.setHeader(Enum
.Header
.SetCookie
, cookie
);
48 * GET request for establishing admin session.
49 * @param {http.ServerResponse} res
52 async
getAdminLogin(res
, ctx
) {
53 const _scope
= _fileScope('getAdminLogin');
54 this.logger
.debug(_scope
, 'called', { ctx
});
56 res
.end(Template
.adminLoginHTML(ctx
, this.options
));
57 this.logger
.info(_scope
, 'finished', { ctx
})
62 * POST request for taking form data to establish admin session.
63 * @param {http.ServerResponse} res
66 async
postAdminLogin(res
, ctx
) {
67 const _scope
= _fileScope('postAdminLogin');
68 this.logger
.debug(_scope
, 'called', { ctx
});
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
});
76 const identifier
= ctx
.parsedBody
['identifier'];
77 const credential
= ctx
.parsedBody
['credential'];
79 const isValidLocalIdentifier
= await
this.authenticator
.isValidIdentifierCredential(identifier
, credential
, ctx
);
80 if (!isValidLocalIdentifier
) {
81 ctx
.errors
.push('Invalid username or password');
84 if (ctx
.errors
.length
) {
85 res
.end(Template
.adminLoginHTML(ctx
, this.options
));
89 // Valid auth, persist the authenticated session
91 authenticatedIdentifier: ctx
.authenticationId
,
93 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
95 res
.setHeader(Enum
.Header
.Location
, './');
97 this.logger
.info(_scope
, 'finished local', { ctx
});
101 let me
, session
, authorizationEndpoint
;
103 me
= new URL(ctx
.parsedBody
['me']);
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.`);
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}'.`);
115 // fetch and parse me for 'authorization_endpoint' relation links
117 authorizationEndpoint
= new URL(profile
.authorizationEndpoint
);
119 ctx
.errors
.push(`Unable to understand the authorization endpoint ('${profile.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`);
123 if (authorizationEndpoint
) {
124 const pkce
= await IndieAuthCommunication
.generatePKCE();
126 authorizationEndpoint: authorizationEndpoint
.href
,
127 state: ctx
.requestId
,
128 codeVerifier: pkce
.codeVerifier
,
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
,
140 }).forEach(([name
, value
]) => authorizationEndpoint
.searchParams
.set(name
, value
));
144 if (ctx
.errors
.length
) {
145 res
.end(Template
.adminLoginHTML(ctx
, this.options
));
149 await
this._sessionCookieSet(res
, session
, this.cookieLifespan
);
150 res
.setHeader(Enum
.Header
.Location
, authorizationEndpoint
.href
);
151 res
.statusCode
= 302; // Found
154 this.logger
.info(_scope
, 'finished indieauth', { ctx
})
159 * GET request to remove current credentials.
160 * @param {http.ServerResponse} res
161 * @param {Object} ctx
163 async
getAdminLogout(res
, ctx
) {
164 const _scope
= _fileScope('getAdminLogout');
165 this.logger
.debug(_scope
, 'called', { ctx
});
167 this._sessionCookieSet(res
, '', 0);
168 res
.statusCode
= 302;
169 res
.setHeader(Enum
.Header
.Location
, './');
172 this.logger
.info(_scope
, 'finished', { ctx
});
177 * GET request for returning IndieAuth redirect.
178 * @param {http.ServerResponse} res
179 * @param {Object} ctx
181 async
getAdminIA(res
, ctx
) {
182 const _scope
= _fileScope('getAdminIA');
183 this.logger
.debug(_scope
, 'called', { ctx
});
188 // Unpack cookie to restore session data
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');
196 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
197 this.logger
.debug(_scope
, 'restored session from cookie', { ctx
});
199 this.logger
.debug(_scope
, 'could not unpack cookie');
200 ctx
.errors
.push('invalid cookie');
204 // Validate unpacked session values
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']);
215 if (ctx
.queryParams
['state'] !== ctx
.session
.state
) {
216 this.logger
.debug(_scope
, 'state mismatch', { ctx
});
217 ctx
.errors
.push('invalid state');
220 const code
= ctx
.queryParams
['code'];
222 this.logger
.debug(_scope
, 'missing code', { ctx
});
223 ctx
.errors
.push('invalid code');
226 let redeemProfileUrl
;
228 redeemProfileUrl
= new URL(ctx
.session
.authorizationEndpoint
);
230 this.logger
.debug(_scope
, 'failed to parse restored session authorization endpoint as url', { ctx
});
231 ctx
.errors
.push('invalid cookie');
234 if (redeemProfileUrl
) {
235 profile
= await
this.indieAuthCommunication
.redeemProfileCode(redeemProfileUrl
, code
, ctx
.session
.codeVerifier
, this.options
.dingus
.selfBaseUrl
, `${this.options.dingus.selfBaseUrl}admin/_ia`);
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');
251 // The endpoints match, all is okay, update our records.
252 ctx
.session
.me
= profile
.me
;
257 if (ctx
.errors
.length
) {
258 await
this._sessionCookieSet(res
, '', 0);
259 res
.end(Template
.adminIAHTML(ctx
, this.options
));
263 // set cookie as auth valid, redirect to admin
265 authenticatedProfile: ctx
.session
.me
,
268 await
this._sessionCookieSet(res
, ctx
.session
, this.cookieLifespan
);
269 res
.statusCode
= 302;
270 res
.setHeader(Enum
.Header
.Location
, './');
273 this.logger
.info(_scope
, 'finished', { ctx
})
279 module
.exports
= SessionManager
;