// Copyright Inrupt Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the // Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import { jest, it, describe, expect, beforeEach } from "@jest/globals"; import { EventEmitter } from "events"; import type * as SolidClientAuthnCore from "@inrupt/solid-client-authn-core"; import { StorageUtility, USER_SESSION_PREFIX, EVENTS, } from "@inrupt/solid-client-authn-core"; import { mockStorageUtility, mockStorage, mockIncomingRedirectHandler, mockHandleIncomingRedirect, mockLogoutHandler, // eslint-disable-next-line import/no-unresolved } from "@inrupt/solid-client-authn-core/mocks"; import { mockLoginHandler } from "./login/__mocks__/LoginHandler"; import { mockSessionInfoManager, SessionCreatorCreateResponse, } from "./sessionInfo/__mocks__/SessionInfoManager"; import ClientAuthentication from "./ClientAuthentication"; import { mockDefaultIssuerConfigFetcher } from "./login/oidc/__mocks__/IssuerConfigFetcher"; jest.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); jest.mock("@inrupt/solid-client-authn-core", () => { const actualCoreModule = jest.requireActual( "@inrupt/solid-client-authn-core", ) as typeof SolidClientAuthnCore; return { ...actualCoreModule, }; }); type SessionStorageOptions = { clientId: string; issuer: string; }; const mockSessionStorage = async ( sessionId: string, options: SessionStorageOptions = { clientId: "https://some.app/registration", issuer: "https://some.issuer", }, ): Promise => { return new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", webId: "https://my.pod/profile#me", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { clientId: options.clientId, issuer: options.issuer, }, }), ); }; describe("ClientAuthentication", () => { const defaultMockStorage = mockStorageUtility({}); const defaultMocks = { loginHandler: mockLoginHandler(), redirectHandler: mockIncomingRedirectHandler(), logoutHandler: mockLogoutHandler(defaultMockStorage), sessionInfoManager: mockSessionInfoManager(defaultMockStorage), issuerConfigFetcher: mockDefaultIssuerConfigFetcher(), }; function getClientAuthentication( mocks: Partial = defaultMocks, ): ClientAuthentication { return new ClientAuthentication( mocks.loginHandler ?? defaultMocks.loginHandler, mocks.redirectHandler ?? defaultMocks.redirectHandler, mocks.logoutHandler ?? defaultMocks.logoutHandler, mocks.sessionInfoManager ?? defaultMocks.sessionInfoManager, mocks.issuerConfigFetcher ?? defaultMocks.issuerConfigFetcher, ); } beforeEach(() => { jest.clearAllMocks(); Object.defineProperty(window, "location", { writable: true, value: { assign: jest.fn() }, }); }); describe("login", () => { const mockEmitter = new EventEmitter(); // TODO: add tests for events & errors it("calls login, and uses the window.location.href for the redirect if no redirectUrl is set", async () => { // Set a current window location which will be the expected URL to be // redirected back to, since we don't pass redirectUrl: window.location.href = "https://coolapp.test/some/redirect"; const clientAuthn = getClientAuthentication(); await clientAuthn.login( { sessionId: "mySession", tokenType: "DPoP", clientId: "coolApp", oidcIssuer: "https://idp.com", }, mockEmitter, ); expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith({ sessionId: "mySession", clientId: "coolApp", redirectUrl: "https://coolapp.test/some/redirect", oidcIssuer: "https://idp.com", clientName: "coolApp", eventEmitter: mockEmitter, tokenType: "DPoP", }); }); it("calls login, and defaults to a DPoP token", async () => { const clientAuthn = getClientAuthentication(); await clientAuthn.login( { sessionId: "mySession", tokenType: "DPoP", clientId: "coolApp", redirectUrl: "https://coolapp.com/redirect", oidcIssuer: "https://idp.com", }, mockEmitter, ); expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith({ sessionId: "mySession", clientId: "coolApp", redirectUrl: "https://coolapp.com/redirect", oidcIssuer: "https://idp.com", clientName: "coolApp", clientSecret: undefined, handleRedirect: undefined, eventEmitter: mockEmitter, tokenType: "DPoP", }); }); it("request a bearer token if specified", async () => { const clientAuthn = getClientAuthentication(); await clientAuthn.login( { sessionId: "mySession", clientId: "coolApp", redirectUrl: "https://coolapp.com/redirect", oidcIssuer: "https://idp.com", tokenType: "Bearer", }, mockEmitter, ); expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith({ sessionId: "mySession", clientId: "coolApp", redirectUrl: "https://coolapp.com/redirect", oidcIssuer: "https://idp.com", clientName: "coolApp", clientSecret: undefined, handleRedirect: undefined, eventEmitter: mockEmitter, tokenType: "Bearer", }); }); it("should clear the local storage when logging in", async () => { const nonEmptyStorage = mockStorageUtility({ someUser: { someKey: "someValue" }, }); await nonEmptyStorage.setForUser( "someUser", { someKey: "someValue" }, { secure: true }, ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(nonEmptyStorage), }); await clientAuthn.login( { sessionId: "someUser", tokenType: "DPoP", clientId: "coolApp", clientName: "coolApp Name", redirectUrl: "https://coolapp.com/redirect", oidcIssuer: "https://idp.com", }, mockEmitter, ); await expect( nonEmptyStorage.getForUser("someUser", "someKey", { secure: true }), ).resolves.toBeUndefined(); await expect( nonEmptyStorage.getForUser("someUser", "someKey", { secure: false }), ).resolves.toBeUndefined(); // This test is only necessary until the key is stored safely await expect( nonEmptyStorage.get("clientKey", { secure: false }), ).resolves.toBeUndefined(); }); it("should not clear the local storage when logging in with prompt set to none", async () => { const nonEmptyStorage = mockStorageUtility({ someUser: { someKey: "someValue" }, }); await nonEmptyStorage.setForUser( "someUser", { someKey: "someValue" }, { secure: false }, ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(nonEmptyStorage), }); await clientAuthn.login( { sessionId: "someUser", tokenType: "DPoP", clientId: "coolApp", clientName: "coolApp Name", redirectUrl: "https://coolapp.com/redirect", oidcIssuer: "https://idp.com", prompt: "none", }, mockEmitter, ); await expect( nonEmptyStorage.getForUser("someUser", "someKey", { secure: false }), ).resolves.toBe("someValue"); }); it("throws if the redirect IRI is a malformed URL", async () => { const clientAuthn = getClientAuthentication(); await expect(() => clientAuthn.login( { sessionId: "someUser", tokenType: "DPoP", clientId: "coolApp", redirectUrl: "not a valid URL", oidcIssuer: "https://idp.com", }, mockEmitter, ), ).rejects.toThrow(); }); it("throws if the redirect IRI contains a hash fragment, with a helpful message", async () => { const clientAuthn = getClientAuthentication(); await expect(() => clientAuthn.login( { sessionId: "someUser", tokenType: "DPoP", clientId: "coolApp", redirectUrl: "https://example.org/redirect#some-fragment", oidcIssuer: "https://idp.com", }, mockEmitter, ), ).rejects.toThrow("hash fragment"); }); it("throws if the redirect IRI contains a reserved query parameter, with a helpful message", async () => { const clientAuthn = getClientAuthentication(); await expect(() => clientAuthn.login( { sessionId: "someUser", tokenType: "DPoP", clientId: "coolApp", redirectUrl: "https://example.org/redirect?state=1234", oidcIssuer: "https://idp.com", }, mockEmitter, ), ).rejects.toThrow("query parameter"); await expect(() => clientAuthn.login( { sessionId: "someUser", tokenType: "DPoP", clientId: "coolApp", redirectUrl: "https://example.org/redirect?code=1234", oidcIssuer: "https://idp.com", }, mockEmitter, ), ).rejects.toThrow("query parameter"); }); it("does not normalize the redirect URL if provided by the user", async () => { const clientAuthn = getClientAuthentication(); await clientAuthn.login( { sessionId: "mySession", clientId: "coolApp", // Note that the redirect IRI does not include a trailing slash. redirectUrl: "https://example.org", oidcIssuer: "https://idp.com", tokenType: "Bearer", }, mockEmitter, ); expect(defaultMocks.loginHandler.handle).toHaveBeenCalledWith( expect.objectContaining({ redirectUrl: "https://example.org", }), ); }); }); describe("fetch", () => { it("calls fetch", async () => { const clientAuthn = getClientAuthentication(); await clientAuthn.fetch("https://html5zombo.com"); expect(fetch).toHaveBeenCalledWith("https://html5zombo.com", undefined); }); }); describe("logout", () => { const mockEmitter = new EventEmitter(); // TODO: add tests for events & errors it("reverts back to un-authenticated fetch on logout", async () => { window.history.replaceState = jest .fn() .mockImplementationOnce((_data, _unused, url) => { // Pretend the current location is updated window.location.href = url as string; }); const clientAuthn = getClientAuthentication(); const unauthFetch = clientAuthn.fetch; const url = "https://coolapp.com/redirect?state=userId&id_token=idToken&access_token=accessToken"; await clientAuthn.handleIncomingRedirect(url, mockEmitter); // Calling the redirect handler should give us an authenticated fetch. expect(clientAuthn.fetch).not.toBe(unauthFetch); await clientAuthn.logout("mySession"); await clientAuthn.fetch("https://example.com", { credentials: "omit", }); // Calling logout should revert back to our un-authenticated fetch. expect(clientAuthn.fetch).toBe(unauthFetch); expect(fetch).toHaveBeenCalledWith("https://example.com", { credentials: "omit", }); }); }); describe("getAllSessionInfo", () => { it("creates a session for the global user", async () => { const clientAuthn = getClientAuthentication(); await expect(() => clientAuthn.getAllSessionInfo()).rejects.toThrow( "Not implemented", ); }); }); describe("getSessionInfo", () => { it("creates a session for the global user", async () => { const sessionInfo = { isLoggedIn: "true", sessionId: "mySession", webId: "https://pod.com/profile/card#me", }; const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager( mockStorageUtility( { "solidClientAuthenticationUser:mySession": { ...sessionInfo }, }, true, ), ), }); const session = await clientAuthn.getSessionInfo("mySession"); // isLoggedIn is stored as a string under the hood, but deserialized as a boolean expect(session).toEqual({ ...sessionInfo, isLoggedIn: true, tokenType: "DPoP", }); }); }); describe("handleIncomingRedirect", () => { const mockEmitter = new EventEmitter(); mockEmitter.emit = jest.fn(); it("calls handle redirect", async () => { window.history.replaceState = jest .fn() .mockImplementationOnce((_data, _unused, url) => { // Pretend the current location is updated window.location.href = url as string; }); const expectedResult = SessionCreatorCreateResponse; const clientAuthn = getClientAuthentication(); const unauthFetch = clientAuthn.fetch; const url = "https://example.org/redirect?state=userId&id_token=idToken&access_token=accessToken"; const redirectInfo = await clientAuthn.handleIncomingRedirect( url, mockEmitter, ); // Our injected mocked response may also contain internal-only data (for // other tests), whereas our response from `handleIncomingRedirect()` can // only contain publicly visible fields. So we need to explicitly check // for individual fields (as opposed to just checking against // entire-response-object-equality). expect(redirectInfo?.sessionId).toEqual(expectedResult.sessionId); expect(redirectInfo?.webId).toEqual(expectedResult.webId); expect(redirectInfo?.isLoggedIn).toEqual(expectedResult.isLoggedIn); expect(redirectInfo?.expirationDate).toEqual( expectedResult.expirationDate, ); expect(defaultMocks.redirectHandler.handle).toHaveBeenCalledWith( url, mockEmitter, undefined, ); // Calling the redirect handler should have updated the fetch. expect(clientAuthn.fetch).not.toBe(unauthFetch); expect(mockEmitter.emit).not.toHaveBeenCalled(); }); it("clears the current IRI from OAuth query parameters in the auth code flow", async () => { window.history.replaceState = jest .fn() .mockImplementationOnce((_data, _unused, url) => { // Pretend the current location is updated window.location.href = url as string; }); const clientAuthn = getClientAuthentication(); const url = "https://coolapp.com/redirect?state=someState&code=someAuthCode&iss=someIssuer"; await clientAuthn.handleIncomingRedirect(url, mockEmitter); expect(history.replaceState).toHaveBeenCalledWith( null, "", "https://coolapp.com/redirect", ); expect(mockEmitter.emit).not.toHaveBeenCalled(); }); it("clears the current IRI from OAuth query parameters even if auth flow fails", async () => { window.history.replaceState = jest .fn() .mockImplementationOnce((_data, _unused, url) => { // Pretend the current location is updated window.location.href = url as string; }); mockHandleIncomingRedirect.mockImplementationOnce(() => Promise.reject(new Error("Something went wrong")), ); const clientAuthn = getClientAuthentication(); const url = "https://coolapp.com/redirect?state=someState&code=someAuthCode"; await clientAuthn.handleIncomingRedirect(url, mockEmitter); expect(window.history.replaceState).toHaveBeenCalledWith( null, "", "https://coolapp.com/redirect", ); expect(mockEmitter.emit).toHaveBeenCalledWith( EVENTS.ERROR, "redirect", new Error("Something went wrong"), ); }); it("preserves non-OAuth query strings", async () => { window.history.replaceState = jest .fn() .mockImplementationOnce((_data, _unused, url) => { // Pretend the current location is updated window.location.href = url as string; }); const clientAuthn = getClientAuthentication(); const url = "https://coolapp.com/redirect?state=someState&code=someAuthCode&someQuery=someValue"; await clientAuthn.handleIncomingRedirect(url, mockEmitter); expect(window.history.replaceState).toHaveBeenCalledWith( null, "", "https://coolapp.com/redirect?someQuery=someValue", ); expect(mockEmitter.emit).not.toHaveBeenCalled(); }); }); describe("validateCurrentSession", () => { it("returns null if the current session has no stored issuer", async () => { const sessionId = "mySession"; const mockedStorage = new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { clientId: "https://some.app/registration", }, }), ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); await expect( clientAuthn.validateCurrentSession(sessionId), ).resolves.toBeNull(); }); it("returns null if the current session has no stored client ID", async () => { const sessionId = "mySession"; const mockedStorage = new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", issuer: "https://some.issuer", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: {}, }), ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); await expect( clientAuthn.validateCurrentSession(sessionId), ).resolves.toBeNull(); }); it("returns the current session if all necessary information are available", async () => { const sessionId = "mySession"; const mockedStorage = await mockSessionStorage(sessionId, { clientId: "https://some.app/registration", issuer: "https://some.issuer", }); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); await expect( clientAuthn.validateCurrentSession(sessionId), ).resolves.toStrictEqual( expect.objectContaining({ issuer: "https://some.issuer", clientAppId: "https://some.app/registration", sessionId, webId: "https://my.pod/profile#me", }), ); }); }); describe("validateCurrentSession", () => { it("returns clientExpiresAt when expiresAt is in storage", async () => { const sessionId = "mySession"; const expiresAt = Math.floor(Date.now() / 1000) + 10000; const mockedStorage = new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", webId: "https://my.pod/profile#me", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { clientId: "https://some.app/registration", clientSecret: "some-secret", issuer: "https://some.issuer", expiresAt: String(expiresAt), }, }), ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); const result = await clientAuthn.validateCurrentSession(sessionId); expect(result).toStrictEqual( expect.objectContaining({ clientExpiresAt: expiresAt, }), ); }); it("returns null when client has expired", async () => { const sessionId = "mySession"; const expiredAt = Math.floor(Date.now() / 1000) - 1000; const mockedStorage = new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", webId: "https://my.pod/profile#me", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { clientId: "https://some.app/registration", clientSecret: "some-secret", issuer: "https://some.issuer", expiresAt: String(expiredAt), }, }), ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); await expect( clientAuthn.validateCurrentSession(sessionId), ).resolves.toBeNull(); }); it("returns session when client has not expired", async () => { const sessionId = "mySession"; const futureExpiry = Math.floor(Date.now() / 1000) + 10000; const mockedStorage = new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", webId: "https://my.pod/profile#me", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { clientId: "https://some.app/registration", clientSecret: "some-secret", issuer: "https://some.issuer", expiresAt: String(futureExpiry), }, }), ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); const result = await clientAuthn.validateCurrentSession(sessionId); expect(result).not.toBeNull(); expect(result).toStrictEqual( expect.objectContaining({ clientAppId: "https://some.app/registration", issuer: "https://some.issuer", }), ); }); it("returns session when clientExpiresAt is 0 (never expires per OIDC DCR spec)", async () => { const sessionId = "mySession"; const mockedStorage = new StorageUtility( mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { isLoggedIn: "true", webId: "https://my.pod/profile#me", }, }), mockStorage({ [`${USER_SESSION_PREFIX}:${sessionId}`]: { clientId: "https://some.app/registration", clientSecret: "some-secret", issuer: "https://some.issuer", expiresAt: "0", }, }), ); const clientAuthn = getClientAuthentication({ sessionInfoManager: mockSessionInfoManager(mockedStorage), }); const result = await clientAuthn.validateCurrentSession(sessionId); expect(result).not.toBeNull(); expect(result).toStrictEqual( expect.objectContaining({ clientAppId: "https://some.app/registration", clientExpiresAt: 0, }), ); }); }); });