1 | /*
|
2 | * Copyright 2021 Inrupt Inc.
|
3 | *
|
4 | * Permission is hereby granted, free of charge, to any person obtaining a copy
|
5 | * of this software and associated documentation files (the "Software"), to deal in
|
6 | * the Software without restriction, including without limitation the rights to use,
|
7 | * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
8 | * Software, and to permit persons to whom the Software is furnished to do so,
|
9 | * subject to the following conditions:
|
10 | *
|
11 | * The above copyright notice and this permission notice shall be included in
|
12 | * all copies or substantial portions of the Software.
|
13 | *
|
14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
15 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
16 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
17 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
18 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
19 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
20 | */
|
21 |
|
22 | /**
|
23 | * @hidden
|
24 | */
|
25 | import { EventEmitter } from "events";
|
26 | import {
|
27 | ILoginInputOptions,
|
28 | ISessionInfo,
|
29 | IStorage,
|
30 | ResourceServerSession,
|
31 | } from "@inrupt/solid-client-authn-core";
|
32 | import { v4 } from "uuid";
|
33 | import ClientAuthentication from "./ClientAuthentication";
|
34 | import { getClientAuthenticationWithDependencies } from "./dependencies";
|
35 |
|
36 | export interface ISessionOptions {
|
37 | /**
|
38 | * A private storage, unreachable to other scripts on the page. Typically in-memory.
|
39 | */
|
40 | secureStorage: IStorage;
|
41 | /**
|
42 | * A storage where non-sensitive information may be stored, potentially longer-lived than the secure storage.
|
43 | */
|
44 | insecureStorage: IStorage;
|
45 | /**
|
46 | * Details about the current session
|
47 | */
|
48 | sessionInfo: ISessionInfo;
|
49 | /**
|
50 | * An instance of the library core. Typically obtained using `getClientAuthenticationWithDependencies`.
|
51 | */
|
52 | clientAuthentication: ClientAuthentication;
|
53 | }
|
54 |
|
55 | /**
|
56 | * A {@link Session} object represents a user's session on an application. The session holds state, as it stores information enabling acces to private resources after login for instance.
|
57 | */
|
58 | export class Session extends EventEmitter {
|
59 | /**
|
60 | * Information regarding the current session.
|
61 | */
|
62 | public readonly info: ISessionInfo;
|
63 |
|
64 | private clientAuthentication: ClientAuthentication;
|
65 |
|
66 | private tokenRequestInProgress = false;
|
67 |
|
68 | /**
|
69 | * Session object constructor. Typically called as follows:
|
70 | *
|
71 | * ```typescript
|
72 | * const session = new Session();
|
73 | * ```
|
74 | * @param sessionOptions The options enabling the correct instantiation of
|
75 | * the session. Either both storages or clientAuthentication are required. For
|
76 | * more information, see {@link ISessionOptions}.
|
77 | * @param sessionId A magic string uniquely identifying the session.
|
78 | *
|
79 | */
|
80 | constructor(
|
81 | sessionOptions: Partial<ISessionOptions> = {},
|
82 | sessionId?: string
|
83 | ) {
|
84 | super();
|
85 |
|
86 | if (sessionOptions.clientAuthentication) {
|
87 | this.clientAuthentication = sessionOptions.clientAuthentication;
|
88 | } else if (sessionOptions.secureStorage && sessionOptions.insecureStorage) {
|
89 | this.clientAuthentication = getClientAuthenticationWithDependencies({
|
90 | secureStorage: sessionOptions.secureStorage,
|
91 | insecureStorage: sessionOptions.insecureStorage,
|
92 | });
|
93 | } else {
|
94 | this.clientAuthentication = getClientAuthenticationWithDependencies({});
|
95 | }
|
96 |
|
97 | if (sessionOptions.sessionInfo) {
|
98 | this.info = {
|
99 | sessionId: sessionOptions.sessionInfo.sessionId,
|
100 | isLoggedIn: false,
|
101 | webId: sessionOptions.sessionInfo.webId,
|
102 | };
|
103 | } else {
|
104 | this.info = {
|
105 | sessionId: sessionId ?? v4(),
|
106 | isLoggedIn: false,
|
107 | };
|
108 | }
|
109 | }
|
110 |
|
111 | /**
|
112 | * Triggers the login process. Note that this method will redirect the user away from your app.
|
113 | *
|
114 | * @param options Parameter to customize the login behaviour. In particular, two options are mandatory: `options.oidcIssuer`, the user's identity provider, and `options.redirectUrl`, the URL to which the user will be redirected after logging in their identity provider.
|
115 | * @returns This method should redirect the user away from the app: it does not return anything. The login process is completed by {@linkcode handleIncomingRedirect}.
|
116 | */
|
117 | // Define these functions as properties so that they don't get accidentally re-bound.
|
118 | // Isn't Javascript fun?
|
119 | login = async (options: ILoginInputOptions): Promise<void> => {
|
120 | await this.clientAuthentication.login(this.info.sessionId, {
|
121 | ...options,
|
122 | });
|
123 | };
|
124 |
|
125 | /**
|
126 | * Fetches data using available login information. If the user is not logged in, this will behave as a regular `fetch`. The signature of this method is identical to the [canonical `fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API).
|
127 | *
|
128 | * @param url The URL from which data should be fetched.
|
129 | * @param init Optional parameters customizing the request, by specifying an HTTP method, headers, a body, etc. Follows the [WHATWG Fetch Standard](https://fetch.spec.whatwg.org/).
|
130 | */
|
131 | fetch = async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
|
132 | return this.clientAuthentication.fetch(url, init);
|
133 | };
|
134 |
|
135 | /**
|
136 | * Logs the user out of the application. This does not log the user out of their Solid identity provider, and should not redirect the user away.
|
137 | */
|
138 | logout = async (): Promise<void> => {
|
139 | await this.clientAuthentication.logout(this.info.sessionId);
|
140 | this.info.isLoggedIn = false;
|
141 | this.emit("logout");
|
142 | };
|
143 |
|
144 | /**
|
145 | * Completes the login process by processing the information provided by the Solid identity provider through redirect.
|
146 | *
|
147 | * @param url The URL of the page handling the redirect, including the query parameters — these contain the information to process the login.
|
148 | */
|
149 | handleIncomingRedirect = async (
|
150 | url: string
|
151 | ): Promise<ISessionInfo | undefined> => {
|
152 | if (this.info.isLoggedIn) {
|
153 | return this.info;
|
154 | }
|
155 | if (this.tokenRequestInProgress) {
|
156 | return undefined;
|
157 | }
|
158 |
|
159 | // Unfortunately, regular sessions are lost when the user refreshes the page or opens a new tab.
|
160 | // While we're figuring out the API for a longer-term solution, as a temporary workaround some
|
161 | // *resource* servers set a cookie that keeps the user logged in after authenticated requests,
|
162 | // and expose the fact that they set it on a special endpoint.
|
163 | // After login, we store that fact in LocalStorage. This means that we can now look for that
|
164 | // data, and if present, indicate that the user is already logged in.
|
165 | // Note that there are a lot of edge cases that won't work well with this approach, so it willl
|
166 | // be removed in due time.
|
167 | const storedSessionCookieReference = window.localStorage.getItem(
|
168 | "tmp-resource-server-session-info"
|
169 | );
|
170 | if (typeof storedSessionCookieReference === "string") {
|
171 | // TOOD: Re-use the type used when writing this data:
|
172 | // https://github.com/inrupt/solid-client-authn-js/pull/920/files#diff-659ac87dfd3711f4cfcea3c7bf6970980f4740fd59df45f04c7977bffaa23e98R118
|
173 | // To keep temporary code together
|
174 | // eslint-disable-next-line no-inner-declarations
|
175 | function isValidSessionCookieReference(
|
176 | reference: Record<string, unknown>
|
177 | ): reference is ResourceServerSession {
|
178 | const resourceServers = Object.keys(
|
179 | (reference as ResourceServerSession).sessions ?? {}
|
180 | );
|
181 | return (
|
182 | typeof (reference as ResourceServerSession).webId === "string" &&
|
183 | resourceServers.length > 0 &&
|
184 | typeof (reference as ResourceServerSession).sessions[
|
185 | resourceServers[0]
|
186 | ].expiration === "number"
|
187 | );
|
188 | }
|
189 | const reference = JSON.parse(storedSessionCookieReference);
|
190 | if (isValidSessionCookieReference(reference)) {
|
191 | const resourceServers = Object.keys(reference.sessions);
|
192 | const webIdOrigin = new URL(reference.webId).hostname;
|
193 | const ownResourceServer = resourceServers.find((resourceServer) => {
|
194 | return new URL(resourceServer).hostname === webIdOrigin;
|
195 | });
|
196 | // Usually the user's WebID is also a Resource server for them,
|
197 | // so we pick the expiration time for that. If it doesn't exist,
|
198 | // we just pick the first (and probably only) one:
|
199 | const relevantServer = ownResourceServer ?? resourceServers[0];
|
200 | // If the cookie is valid for fewer than five minutes,
|
201 | // pretend it's not valid anymore already, to avoid small misalignments
|
202 | // resulting in invalid states:
|
203 | if (
|
204 | reference.sessions[relevantServer].expiration - Date.now() >
|
205 | 5 * 60 * 1000
|
206 | ) {
|
207 | this.info.isLoggedIn = true;
|
208 | this.info.webId = reference.webId;
|
209 | return this.info;
|
210 | }
|
211 | }
|
212 | }
|
213 | // end of temporary workaround.
|
214 |
|
215 | this.tokenRequestInProgress = true;
|
216 | const sessionInfo = await this.clientAuthentication.handleIncomingRedirect(
|
217 | url
|
218 | );
|
219 | if (sessionInfo) {
|
220 | this.info.isLoggedIn = sessionInfo.isLoggedIn;
|
221 | this.info.webId = sessionInfo.webId;
|
222 | this.info.sessionId = sessionInfo.sessionId;
|
223 | if (sessionInfo.isLoggedIn) {
|
224 | // The login event can only be triggered **after** the user has been
|
225 | // redirected from the IdP with access and ID tokens.
|
226 | this.emit("login");
|
227 | }
|
228 | }
|
229 | this.tokenRequestInProgress = false;
|
230 | return sessionInfo;
|
231 | };
|
232 |
|
233 | /**
|
234 | * Register a callback function to be called when a user completes login.
|
235 | *
|
236 | * The callback is called when {@link handleIncomingRedirect} completes successfully.
|
237 | *
|
238 | * @param callback The function called when a user completes login.
|
239 | */
|
240 | onLogin(callback: () => unknown): void {
|
241 | this.on("login", callback);
|
242 | }
|
243 |
|
244 | /**
|
245 | * Register a callback function to be called when a user logs out:
|
246 | *
|
247 | * @param callback The function called when a user completes logout.
|
248 | */
|
249 | onLogout(callback: () => unknown): void {
|
250 | this.on("logout", callback);
|
251 | }
|
252 | }
|