@inrupt/solid-client-authn-browser 2.2.7 → 2.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inrupt/solid-client-authn-browser",
3
- "version": "2.2.7",
3
+ "version": "2.4.0",
4
4
  "license": "MIT",
5
5
  "types": "dist/index.d.ts",
6
6
  "browser": "dist/index.js",
@@ -26,18 +26,18 @@
26
26
  "build-docs-preview-site": "npm run build-api-docs; cd docs/api; make html"
27
27
  },
28
28
  "devDependencies": {
29
- "@types/node": "^22.7.9",
29
+ "@types/node": "^22.14.1",
30
30
  "@types/uuid": "^10.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@inrupt/oidc-client-ext": "^2.2.7",
34
- "@inrupt/solid-client-authn-core": "^2.2.7",
33
+ "@inrupt/oidc-client-ext": "^2.4.0",
34
+ "@inrupt/solid-client-authn-core": "^2.4.0",
35
35
  "events": "^3.3.0",
36
36
  "jose": "^5.1.3",
37
- "uuid": "^10.0.0"
37
+ "uuid": "^11.1.0"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
42
- "gitHead": "3b3e6fb05cc81db4baceab8c1cf9eccb8c7592a2"
42
+ "gitHead": "1bfff9da8474e38a1d87216aa6aff1d1c7065d1b"
43
43
  }
@@ -230,6 +230,35 @@ describe("ClientAuthentication", () => {
230
230
  ).resolves.toBeUndefined();
231
231
  });
232
232
 
233
+ it("should not clear the local storage when logging in with prompt set to none", async () => {
234
+ const nonEmptyStorage = mockStorageUtility({
235
+ someUser: { someKey: "someValue" },
236
+ });
237
+ await nonEmptyStorage.setForUser(
238
+ "someUser",
239
+ { someKey: "someValue" },
240
+ { secure: false },
241
+ );
242
+ const clientAuthn = getClientAuthentication({
243
+ sessionInfoManager: mockSessionInfoManager(nonEmptyStorage),
244
+ });
245
+ await clientAuthn.login(
246
+ {
247
+ sessionId: "someUser",
248
+ tokenType: "DPoP",
249
+ clientId: "coolApp",
250
+ clientName: "coolApp Name",
251
+ redirectUrl: "https://coolapp.com/redirect",
252
+ oidcIssuer: "https://idp.com",
253
+ prompt: "none",
254
+ },
255
+ mockEmitter,
256
+ );
257
+ await expect(
258
+ nonEmptyStorage.getForUser("someUser", "someKey", { secure: false }),
259
+ ).resolves.toBe("someValue");
260
+ });
261
+
233
262
  it("throws if the redirect IRI is a malformed URL", async () => {
234
263
  const clientAuthn = getClientAuthentication();
235
264
  await expect(() =>
@@ -49,12 +49,11 @@ export default class ClientAuthentication extends ClientAuthenticationBase {
49
49
  eventEmitter: EventEmitter,
50
50
  ): Promise<void> => {
51
51
  // In order to get a clean start, make sure that the session is logged out
52
- // on login.
53
- // But we may want to preserve our client application info, particularly if
54
- // we used Dynamic Client Registration to register (since we don't
55
- // necessarily want the user to have to register this app each time they
56
- // login).
57
- await this.sessionInfoManager.clear(options.sessionId);
52
+ // on login, except when doing a silent login so that Dynamic Client information
53
+ // is preserved.
54
+ if (options.prompt !== "none") {
55
+ await this.sessionInfoManager.clear(options.sessionId);
56
+ }
58
57
 
59
58
  // In the case of the user hitting the 'back' button in their browser, they
60
59
  // could return to a previous redirect URL that contains OIDC params that
@@ -63,7 +63,7 @@ const mockLocation = (mockedLocation: string) => {
63
63
  delete (window as any).history.replaceState;
64
64
 
65
65
  // Set our window's location to our test value.
66
- window.location = {
66
+ (window as any).location = {
67
67
  href: mockedLocation,
68
68
  } as Location;
69
69
  window.history.replaceState = jest.fn();
@@ -25,9 +25,17 @@ import {
25
25
  } from "@inrupt/solid-client-authn-core/mocks";
26
26
  import { jest, it, describe, expect } from "@jest/globals";
27
27
  import { randomUUID } from "crypto";
28
+ import type * as OidcClientExt from "@inrupt/oidc-client-ext";
29
+ import type { IOpenIdDynamicClient } from "core";
28
30
  import ClientRegistrar from "./ClientRegistrar";
29
31
  import { IssuerConfigFetcherFetchConfigResponse } from "./__mocks__/IssuerConfigFetcher";
30
32
 
33
+ jest.mock("@inrupt/oidc-client-ext", () => {
34
+ return {
35
+ registerClient: jest.fn(),
36
+ };
37
+ });
38
+
31
39
  /**
32
40
  * Test for ClientRegistrar
33
41
  */
@@ -42,24 +50,19 @@ describe("ClientRegistrar", () => {
42
50
  }
43
51
 
44
52
  describe("getClient", () => {
45
- it("properly performs dynamic registration", async () => {
46
- const clientId = randomUUID();
47
- const clientSecret = randomUUID();
48
- // FIXME: this should mock out oidc-client-ext, instead of mimicking the
49
- // actual OIDC provider response.
50
- const mockFetch = (jest.fn() as any).mockResolvedValueOnce(
51
- /* eslint-disable camelcase */
52
- new Response(
53
- JSON.stringify({
54
- client_id: clientId,
55
- client_secret: clientSecret,
56
- redirect_uris: ["https://example.com"],
57
- id_token_signed_response_alg: "RS256",
58
- }),
59
- ),
60
- /* eslint-enable camelcase */
61
- );
62
- global.fetch = mockFetch;
53
+ it("performs dynamic registration if no client is in storage", async () => {
54
+ // Setup registration mock.
55
+ const client: IOpenIdDynamicClient = {
56
+ clientId: randomUUID(),
57
+ clientSecret: randomUUID(),
58
+ expiresAt: Date.now() + 1000,
59
+ clientType: "dynamic",
60
+ };
61
+ const { registerClient: mockedRegisterClient } = jest.requireMock(
62
+ "@inrupt/oidc-client-ext",
63
+ ) as jest.Mocked<typeof OidcClientExt>;
64
+ mockedRegisterClient.mockResolvedValueOnce(client);
65
+
63
66
  const clientRegistrar = getClientRegistrar({
64
67
  storage: mockStorageUtility({}),
65
68
  });
@@ -75,40 +78,20 @@ describe("ClientRegistrar", () => {
75
78
  registrationEndpoint: registrationUrl,
76
79
  },
77
80
  ),
78
- ).toMatchObject({
79
- clientId,
80
- clientSecret,
81
- });
82
- expect(mockFetch).toHaveBeenCalledWith(registrationUrl.toString(), {
83
- method: "POST",
84
- headers: {
85
- "Content-Type": "application/json",
86
- },
87
- body: JSON.stringify({
88
- /* eslint-disable camelcase */
89
- application_type: "web",
90
- redirect_uris: ["https://example.com"],
91
- subject_type: "public",
92
- token_endpoint_auth_method: "client_secret_basic",
93
- id_token_signed_response_alg: "RS256",
94
- grant_types: ["authorization_code", "refresh_token"],
95
- /* eslint-enable camelcase */
96
- }),
97
- });
81
+ ).toMatchObject(client);
98
82
  });
99
83
 
100
84
  it("can register a public client without secret", async () => {
101
- const mockFetch = (jest.fn() as any).mockResolvedValueOnce(
102
- /* eslint-disable camelcase */
103
- new Response(
104
- JSON.stringify({
105
- client_id: "abcd",
106
- redirect_uris: ["https://example.com"],
107
- }),
108
- ),
109
- /* eslint-enable camelcase */
110
- );
111
- global.fetch = mockFetch;
85
+ // Setup registration mock.
86
+ const client: IOpenIdDynamicClient = {
87
+ clientId: randomUUID(),
88
+ clientType: "dynamic",
89
+ };
90
+ const { registerClient: mockedRegisterClient } = jest.requireMock(
91
+ "@inrupt/oidc-client-ext",
92
+ ) as jest.Mocked<typeof OidcClientExt>;
93
+ mockedRegisterClient.mockResolvedValueOnce(client);
94
+
112
95
  const clientRegistrar = getClientRegistrar({
113
96
  storage: mockStorageUtility({}),
114
97
  });
@@ -138,20 +121,18 @@ describe("ClientRegistrar", () => {
138
121
  },
139
122
  IssuerConfigFetcherFetchConfigResponse,
140
123
  ),
141
- ).rejects.toThrow(
142
- "Dynamic Registration could not be completed because the issuer has no registration endpoint.",
143
- );
124
+ ).rejects.toThrow();
144
125
  });
145
126
 
146
127
  it("handles a failure to dynamically register elegantly", async () => {
147
- const mockFetch = (jest.fn() as any).mockResolvedValueOnce(
148
- /* eslint-disable camelcase */
149
- new Response('{"error":"bad stuff that\'s an error"}', {
150
- status: 400,
151
- }),
152
- /* eslint-enable camelcase */
128
+ // Setup registration mock.
129
+ const { registerClient: mockedRegisterClient } = jest.requireMock(
130
+ "@inrupt/oidc-client-ext",
131
+ ) as jest.Mocked<typeof OidcClientExt>;
132
+ mockedRegisterClient.mockRejectedValueOnce(
133
+ new Error("Dynamic client registration failed"),
153
134
  );
154
- global.fetch = mockFetch;
135
+
155
136
  const clientRegistrar = getClientRegistrar({
156
137
  storage: mockStorageUtility({}),
157
138
  });
@@ -167,20 +148,20 @@ describe("ClientRegistrar", () => {
167
148
  registrationEndpoint: registrationUrl,
168
149
  },
169
150
  ),
170
- ).rejects.toThrow(
171
- "Client registration failed: [Error: Dynamic client registration failed: bad stuff that's an error - ]",
172
- );
151
+ ).rejects.toThrow("Client registration failed");
173
152
  });
174
153
 
175
154
  it("retrieves client id and secret from storage if they are present", async () => {
176
155
  const clientId = randomUUID();
177
156
  const clientSecret = randomUUID();
157
+ const expiresAt = Math.ceil((Date.now() + 100_000) / 1000);
178
158
  const clientRegistrar = getClientRegistrar({
179
159
  storage: mockStorageUtility(
180
160
  {
181
161
  "solidClientAuthenticationUser:mySession": {
182
162
  clientId,
183
163
  clientSecret,
164
+ expiresAt: String(expiresAt),
184
165
  clientType: "dynamic",
185
166
  },
186
167
  },
@@ -198,23 +179,115 @@ describe("ClientRegistrar", () => {
198
179
  );
199
180
  expect(client.clientId).toBe(clientId);
200
181
  expect(client.clientSecret).toBe(clientSecret);
182
+ expect(client.clientType).toBe("dynamic");
183
+ expect((client as IOpenIdDynamicClient).expiresAt).toBe(expiresAt);
201
184
  });
202
185
 
203
- it("saves dynamic registration information", async () => {
204
- const clientId = randomUUID();
205
- const clientSecret = randomUUID();
206
- const mockFetch = (jest.fn() as any).mockResolvedValueOnce(
207
- /* eslint-disable camelcase */
208
- new Response(
209
- JSON.stringify({
210
- client_id: clientId,
211
- client_secret: clientSecret,
212
- redirect_uris: ["https://example.com"],
213
- }),
186
+ it("performs dynamic registration if an expired client is present in storage", async () => {
187
+ // Setup the registration mock.
188
+ const renewedClient: IOpenIdDynamicClient = {
189
+ clientId: randomUUID(),
190
+ clientSecret: randomUUID(),
191
+ // Date.now is in ms, and the expiration date is in seconds.
192
+ expiresAt: Math.ceil((Date.now() + 100_000) / 1000),
193
+ clientType: "dynamic",
194
+ };
195
+ const { registerClient: mockedRegisterClient } = jest.requireMock(
196
+ "@inrupt/oidc-client-ext",
197
+ ) as jest.Mocked<typeof OidcClientExt>;
198
+ mockedRegisterClient.mockResolvedValueOnce(renewedClient);
199
+ const expiredClient: IOpenIdDynamicClient = {
200
+ clientId: randomUUID(),
201
+ clientSecret: randomUUID(),
202
+ // Date.now is in ms, and the expiration date is in seconds.
203
+ expiresAt: Math.ceil((Date.now() - 100_000) / 1000),
204
+ clientType: "dynamic",
205
+ };
206
+
207
+ // Setup the expired client in storage mock.
208
+ const clientRegistrar = getClientRegistrar({
209
+ storage: mockStorageUtility(
210
+ {
211
+ "solidClientAuthenticationUser:mySession": {
212
+ clientId: expiredClient.clientId,
213
+ clientSecret: expiredClient.clientSecret,
214
+ expiresAt: String(expiredClient.expiresAt),
215
+ clientType: expiredClient.clientType,
216
+ },
217
+ },
218
+ false,
214
219
  ),
215
- /* eslint-enable camelcase */
216
- );
217
- global.fetch = mockFetch;
220
+ });
221
+ const registrationUrl = "https://idp.com/register";
222
+ expect(
223
+ await clientRegistrar.getClient(
224
+ {
225
+ sessionId: "mySession",
226
+ redirectUrl: "https://example.com",
227
+ },
228
+ {
229
+ ...IssuerConfigFetcherFetchConfigResponse,
230
+ registrationEndpoint: registrationUrl,
231
+ },
232
+ ),
233
+ ).toMatchObject(renewedClient);
234
+ });
235
+
236
+ it("performs dynamic registration if a confidential client is missing an expiration date (legacy case)", async () => {
237
+ // Setup the registration mock.
238
+ const renewedClient: IOpenIdDynamicClient = {
239
+ clientId: randomUUID(),
240
+ clientSecret: randomUUID(),
241
+ expiresAt: Date.now() + 1000,
242
+ clientType: "dynamic",
243
+ };
244
+ const { registerClient: mockedRegisterClient } = jest.requireMock(
245
+ "@inrupt/oidc-client-ext",
246
+ ) as jest.Mocked<typeof OidcClientExt>;
247
+ mockedRegisterClient.mockResolvedValueOnce(renewedClient);
248
+
249
+ // Setup the legacy client in storage mock.
250
+ const clientRegistrar = getClientRegistrar({
251
+ storage: mockStorageUtility(
252
+ {
253
+ "solidClientAuthenticationUser:mySession": {
254
+ clientId: randomUUID(),
255
+ clientSecret: randomUUID(),
256
+ clientType: "dynamic",
257
+ // The expiration date is missing.
258
+ },
259
+ },
260
+ false,
261
+ ),
262
+ });
263
+ const registrationUrl = "https://idp.com/register";
264
+ expect(
265
+ await clientRegistrar.getClient(
266
+ {
267
+ sessionId: "mySession",
268
+ redirectUrl: "https://example.com",
269
+ },
270
+ {
271
+ ...IssuerConfigFetcherFetchConfigResponse,
272
+ registrationEndpoint: registrationUrl,
273
+ },
274
+ ),
275
+ ).toMatchObject(renewedClient);
276
+ });
277
+
278
+ it("saves dynamic registration information", async () => {
279
+ // Setup registration mock.
280
+ const client: IOpenIdDynamicClient = {
281
+ clientId: randomUUID(),
282
+ clientSecret: randomUUID(),
283
+ expiresAt: Math.ceil((Date.now() + 10_000) / 1000),
284
+ clientType: "dynamic",
285
+ };
286
+ const { registerClient: mockedRegisterClient } = jest.requireMock(
287
+ "@inrupt/oidc-client-ext",
288
+ ) as jest.Mocked<typeof OidcClientExt>;
289
+ mockedRegisterClient.mockResolvedValueOnce(client);
290
+
218
291
  const myStorage = mockStorageUtility({});
219
292
  const clientRegistrar = getClientRegistrar({
220
293
  storage: myStorage,
@@ -234,10 +307,13 @@ describe("ClientRegistrar", () => {
234
307
 
235
308
  await expect(
236
309
  myStorage.getForUser("mySession", "clientId", { secure: false }),
237
- ).resolves.toBe(clientId);
310
+ ).resolves.toBe(client.clientId);
238
311
  await expect(
239
312
  myStorage.getForUser("mySession", "clientSecret", { secure: false }),
240
- ).resolves.toBe(clientSecret);
313
+ ).resolves.toBe(client.clientSecret);
314
+ await expect(
315
+ myStorage.getForUser("mySession", "expiresAt", { secure: false }),
316
+ ).resolves.toBe(String(client.expiresAt));
241
317
  });
242
318
  });
243
319
  });
@@ -50,6 +50,7 @@ export default class ClientRegistrar implements IClientRegistrar {
50
50
  const [
51
51
  storedClientId,
52
52
  storedClientSecret,
53
+ expiresAt,
53
54
  storedClientName,
54
55
  storedClientType,
55
56
  ] = await Promise.all([
@@ -59,6 +60,9 @@ export default class ClientRegistrar implements IClientRegistrar {
59
60
  this.storageUtility.getForUser(options.sessionId, "clientSecret", {
60
61
  secure: false,
61
62
  }),
63
+ this.storageUtility.getForUser(options.sessionId, "expiresAt", {
64
+ secure: false,
65
+ }),
62
66
  this.storageUtility.getForUser(options.sessionId, "clientName", {
63
67
  secure: false,
64
68
  }),
@@ -66,14 +70,32 @@ export default class ClientRegistrar implements IClientRegistrar {
66
70
  secure: false,
67
71
  }),
68
72
  ]);
69
- if (storedClientId && isKnownClientType(storedClientType)) {
70
- return {
71
- clientId: storedClientId,
72
- clientSecret: storedClientSecret,
73
- clientName: storedClientName,
74
- // Note: static clients are not applicable in a browser context.
75
- clientType: storedClientType,
76
- } as IClient;
73
+ // -1 is used as a default to identify legacy cases when a value should
74
+ // have been stored, but wasn't. It will be treated as an expired client.
75
+ const expirationDate =
76
+ expiresAt !== undefined ? Number.parseInt(expiresAt, 10) : -1;
77
+ // Expiration is only applicable to confidential clients.
78
+ // Note that Date.now() is in milliseconds, and expirationDate in seconds.
79
+ const expired =
80
+ storedClientSecret !== undefined &&
81
+ Math.floor(Date.now() / 1000) > expirationDate;
82
+ if (storedClientId && isKnownClientType(storedClientType) && !expired) {
83
+ return storedClientSecret !== undefined
84
+ ? {
85
+ clientId: storedClientId,
86
+ clientSecret: storedClientSecret,
87
+ clientName: storedClientName,
88
+ // Note: static clients are not applicable in a browser context.
89
+ clientType: "dynamic",
90
+ expiresAt: expirationDate,
91
+ }
92
+ : ({
93
+ clientId: storedClientId,
94
+ clientName: storedClientName,
95
+ // Note: static clients are not applicable in a browser context.
96
+ clientType: storedClientType,
97
+ // The type assertion is required even though the type should match the declaration.
98
+ } as IClient);
77
99
  }
78
100
 
79
101
  try {
@@ -83,8 +105,9 @@ export default class ClientRegistrar implements IClientRegistrar {
83
105
  clientId: registeredClient.clientId,
84
106
  clientType: "dynamic",
85
107
  };
86
- if (registeredClient.clientSecret) {
108
+ if (registeredClient.clientSecret !== undefined) {
87
109
  infoToSave.clientSecret = registeredClient.clientSecret;
110
+ infoToSave.expiresAt = String(registeredClient.expiresAt);
88
111
  }
89
112
  if (registeredClient.idTokenSignedResponseAlg) {
90
113
  infoToSave.idTokenSignedResponseAlg =
@@ -98,7 +121,7 @@ export default class ClientRegistrar implements IClientRegistrar {
98
121
  });
99
122
  return registeredClient;
100
123
  } catch (error) {
101
- throw new Error(`Client registration failed: [${error}]`);
124
+ throw new Error(`Client registration failed.`, { cause: error });
102
125
  }
103
126
  }
104
127
  }
@@ -49,14 +49,14 @@ describe("Redirector", () => {
49
49
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
50
50
  // @ts-ignore
51
51
  delete window.history.replaceState;
52
- window.location = {
52
+ (window as any).location = {
53
53
  href: "https://coolSite.com",
54
54
  } as Location;
55
55
  window.history.replaceState = jest.fn();
56
56
  });
57
57
 
58
58
  afterEach(() => {
59
- window.location = location;
59
+ (window as any).location = location;
60
60
  window.history.replaceState = replaceState;
61
61
  });
62
62
 
@@ -31,6 +31,8 @@ export const ClientRegistrarResponse: IClient = {
31
31
  clientId: "abcde",
32
32
  clientSecret: "12345",
33
33
  clientType: "dynamic",
34
+ // 5000-01-07
35
+ expiresAt: 95618140501000,
34
36
  };
35
37
 
36
38
  export const PublicClientRegistrarResponse: IClient = {
@@ -160,7 +160,7 @@ export class AuthCodeRedirectHandler implements IIncomingRedirectHandler {
160
160
  };
161
161
  }
162
162
 
163
- const authFetch = await buildAuthenticatedFetch(tokens.accessToken, {
163
+ const authFetch = buildAuthenticatedFetch(tokens.accessToken, {
164
164
  dpopKey: tokens.dpopKey,
165
165
  refreshOptions,
166
166
  eventEmitter,
@@ -264,6 +264,7 @@ describe("AuthorizationCodeWithPkceOidcHandler", () => {
264
264
  ...standardOidcOptions.client,
265
265
  clientType: "dynamic",
266
266
  clientSecret: "I can't cook because I only drink Soylent",
267
+ expiresAt: 95618140501000,
267
268
  },
268
269
  issuerConfiguration: {
269
270
  ...standardOidcOptions.issuerConfiguration,
package/tsconfig.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "extends": "../../tsconfig.build.json",
3
3
 
4
4
  "compilerOptions": {
5
- "lib": ["es2018", "dom"],
5
+ "lib": ["es2022", "dom"],
6
6
  "outDir": "./dist"
7
7
  },
8
8