1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 | import "reflect-metadata";
|
23 | import { Response as NodeResponse } from "node-fetch";
|
24 | import {
|
25 | IIssuerConfig,
|
26 | mockStorageUtility,
|
27 | } from "@inrupt/solid-client-authn-core";
|
28 | import { JSONWebKey } from "jose";
|
29 | import {
|
30 | IssuerConfigFetcherMock,
|
31 | IssuerConfigFetcherFetchConfigResponse,
|
32 | } from "../../../src/login/oidc/__mocks__/IssuerConfigFetcher";
|
33 | import TokenRequester from "../../../src/login/oidc/TokenRequester";
|
34 | import {
|
35 | ClientRegistrarMock,
|
36 | PublicClientRegistrarMock,
|
37 | } from "../../../src/login/oidc/__mocks__/ClientRegistrar";
|
38 |
|
39 | const mockJWK = {
|
40 | kty: "EC",
|
41 | kid: "oOArcXxcwvsaG21jAx_D5CHr4BgVCzCEtlfmNFQtU0s",
|
42 | alg: "ES256",
|
43 | crv: "P-256",
|
44 | x: "0dGe_s-urLhD3mpqYqmSXrqUZApVV5ZNxMJXg7Vp-2A",
|
45 | y: "-oMe9gGkpfIrnJ0aiSUHMdjqYVm5ZrGCeQmRKoIIfj8",
|
46 | d: "yR1bCsR7m4hjFCvWo8Jw3OfNR4aiYDAFbBD9nkudJKM",
|
47 | };
|
48 |
|
49 | jest.mock("@inrupt/oidc-client-ext", () => {
|
50 | return {
|
51 | generateJwkForDpop: async (): Promise<typeof mockJWK> => mockJWK,
|
52 | createDpopHeader: async (
|
53 | _audience: URL,
|
54 | _method: string,
|
55 | _jwt: JSONWebKey
|
56 | ): Promise<string> => "someToken",
|
57 | decodeJwt: async (_jwt: string): Promise<Record<string, unknown>> => {
|
58 | return {
|
59 | sub: "https://some.webid",
|
60 | };
|
61 | },
|
62 | };
|
63 | });
|
64 |
|
65 | describe("TokenRequester", () => {
|
66 | const defaultMocks = {
|
67 | storageUtility: mockStorageUtility({}),
|
68 | issueConfigFetcher: IssuerConfigFetcherMock,
|
69 | clientRegistrar: ClientRegistrarMock,
|
70 | };
|
71 |
|
72 | function getTokenRequester(
|
73 | mocks: Partial<typeof defaultMocks> = defaultMocks
|
74 | ): TokenRequester {
|
75 | return new TokenRequester(
|
76 | mocks.storageUtility ?? defaultMocks.storageUtility,
|
77 | mocks.issueConfigFetcher ?? defaultMocks.issueConfigFetcher,
|
78 | mocks.clientRegistrar ?? ClientRegistrarMock
|
79 | );
|
80 | }
|
81 |
|
82 | const defaultReturnValues: {
|
83 | storageIdp: string;
|
84 | issuerConfig: IIssuerConfig;
|
85 | responseBody: string;
|
86 | jwt: Record<string, string>;
|
87 | } = {
|
88 | storageIdp: "https://idp.com",
|
89 |
|
90 | issuerConfig: {
|
91 | ...IssuerConfigFetcherFetchConfigResponse,
|
92 | grantTypesSupported: ["refresh_token"],
|
93 | },
|
94 |
|
95 | responseBody: JSON.stringify({
|
96 |
|
97 | id_token: "abcd",
|
98 | access_token: "1234",
|
99 | refresh_token: "!@#$",
|
100 |
|
101 | }),
|
102 |
|
103 | jwt: {
|
104 | sub: "https://jackson.solid.community/profile/card#me",
|
105 | },
|
106 | };
|
107 |
|
108 | async function setUpMockedReturnValues(
|
109 | values: Partial<typeof defaultReturnValues>
|
110 | ): Promise<void> {
|
111 | await defaultMocks.storageUtility.setForUser("global", {
|
112 | issuer: values.storageIdp ?? defaultReturnValues.storageIdp,
|
113 | });
|
114 |
|
115 | const issuerConfig =
|
116 | values.issuerConfig ?? defaultReturnValues.issuerConfig;
|
117 | defaultMocks.issueConfigFetcher.fetchConfig.mockResolvedValueOnce(
|
118 | issuerConfig
|
119 | );
|
120 |
|
121 | const mockedFetch = jest
|
122 | .fn()
|
123 | .mockResolvedValueOnce(
|
124 | (new NodeResponse(
|
125 | values.responseBody ?? defaultReturnValues.responseBody
|
126 | ) as unknown) as Response
|
127 | );
|
128 | window.fetch = mockedFetch;
|
129 | }
|
130 |
|
131 | it("Properly follows the refresh flow", async () => {
|
132 | await setUpMockedReturnValues({});
|
133 | const TokenRefresher = getTokenRequester({
|
134 | clientRegistrar: PublicClientRegistrarMock,
|
135 | });
|
136 |
|
137 | await TokenRefresher.request("global", {
|
138 | grant_type: "refresh_token",
|
139 | refresh_token: "thisIsARefreshToken",
|
140 | });
|
141 |
|
142 | expect(window.fetch).toHaveBeenCalledWith(
|
143 | IssuerConfigFetcherFetchConfigResponse.tokenEndpoint,
|
144 | {
|
145 | method: "POST",
|
146 | headers: {
|
147 | DPoP: "someToken",
|
148 | "content-type": "application/x-www-form-urlencoded",
|
149 | },
|
150 | body:
|
151 | "grant_type=refresh_token&refresh_token=thisIsARefreshToken&client_id=abcde",
|
152 | }
|
153 | );
|
154 | });
|
155 |
|
156 | it("Adds an authorization header if a client secret is present", async () => {
|
157 | const TokenRefresher = getTokenRequester();
|
158 | window.fetch = jest.fn().mockResolvedValueOnce(
|
159 | new NodeResponse(
|
160 | JSON.stringify({
|
161 |
|
162 | access_token:
|
163 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJodHRwczovL3NvbWUucG9kL3dlYmlkI21lIiwianRpIjoiMjFiNDhlOTgtNzA4YS00Y2UzLTk2NmMtYTk2M2UwMDM2ZDBiIiwiaWF0IjoxNTk1ODQxODAxLCJleHAiOjE1OTU4NDU0MDF9.MGqDiny3pzhlSOJ52cJWJA84J47b9p5kkuuJVPB-dVg",
|
164 | id_token: "myIdToken",
|
165 | refresh_token: "myResfreshToken",
|
166 |
|
167 | })
|
168 | )
|
169 | );
|
170 |
|
171 | await TokenRefresher.request("global", {
|
172 | grant_type: "refresh_token",
|
173 | refresh_token: "thisIsARefreshToken",
|
174 | });
|
175 |
|
176 | expect(window.fetch).toHaveBeenCalledWith(
|
177 | IssuerConfigFetcherFetchConfigResponse.tokenEndpoint,
|
178 | {
|
179 | method: "POST",
|
180 | headers: {
|
181 | DPoP: "someToken",
|
182 | Authorization: "Basic YWJjZGU6MTIzNDU=",
|
183 | "content-type": "application/x-www-form-urlencoded",
|
184 | },
|
185 | body:
|
186 | "grant_type=refresh_token&refresh_token=thisIsARefreshToken&client_id=abcde",
|
187 | }
|
188 | );
|
189 | });
|
190 |
|
191 | it("Fails elegantly if the idp returns a bad value", async () => {
|
192 | await setUpMockedReturnValues({
|
193 | responseBody: JSON.stringify({
|
194 |
|
195 | id_token: "ohNoThereIsNoAccessToken",
|
196 | }),
|
197 | });
|
198 | const TokenRefresher = getTokenRequester();
|
199 | await expect(
|
200 |
|
201 | TokenRefresher.request("global", {
|
202 | grant_type: "refresh_token",
|
203 | refresh_token: "thisIsARefreshToken",
|
204 | })
|
205 |
|
206 | ).rejects.toThrow("IDP token route returned an invalid response.");
|
207 | });
|
208 |
|
209 | it("Fails elegantly if the issuer does not support refresh tokens", async () => {
|
210 | await setUpMockedReturnValues({
|
211 | issuerConfig: {
|
212 | ...IssuerConfigFetcherFetchConfigResponse,
|
213 |
|
214 | grantTypesSupported: ["id_token"],
|
215 | },
|
216 | });
|
217 | const TokenRefresher = getTokenRequester();
|
218 | await expect(
|
219 |
|
220 | TokenRefresher.request("global", {
|
221 | grant_type: "refresh_token",
|
222 | refresh_token: "thisIsARefreshToken",
|
223 | })
|
224 |
|
225 | ).rejects.toThrow(
|
226 | "The issuer [https://idp.com] does not support the [refresh_token] grant"
|
227 | );
|
228 | });
|
229 |
|
230 | it("Fails elegantly if the issuer does not have a token endpoint", async () => {
|
231 | const mockIssuerConfig = {
|
232 | ...IssuerConfigFetcherFetchConfigResponse,
|
233 | tokenEndpoint: null,
|
234 |
|
235 | grantTypesSupported: ["refresh_token"],
|
236 | };
|
237 | const mockIssuerConfigFetcher = defaultMocks.issueConfigFetcher;
|
238 |
|
239 |
|
240 | mockIssuerConfigFetcher.fetchConfig.mockResolvedValueOnce(mockIssuerConfig);
|
241 |
|
242 | const TokenRefresher = getTokenRequester({
|
243 | issueConfigFetcher: mockIssuerConfigFetcher,
|
244 | });
|
245 | await expect(
|
246 |
|
247 | TokenRefresher.request("global", {
|
248 | grant_type: "refresh_token",
|
249 | refresh_token: "thisIsARefreshToken",
|
250 | })
|
251 |
|
252 |
|
253 |
|
254 | ).rejects.toThrow("does not have a token endpoint");
|
255 | });
|
256 |
|
257 |
|
258 |
|
259 | it.skip("Fails elegantly if the access token does not have a sub claim", async () => {
|
260 | await setUpMockedReturnValues({
|
261 | jwt: {
|
262 | iss: "https://idp.com",
|
263 | },
|
264 | });
|
265 | const TokenRefresher = getTokenRequester();
|
266 | await expect(
|
267 |
|
268 | TokenRefresher.request("global", {
|
269 | grant_type: "refresh_token",
|
270 | refresh_token: "thisIsARefreshToken",
|
271 | })
|
272 |
|
273 | ).rejects.toThrow(
|
274 | "The Authorization Server returned a bad token (i.e. when decoded we did not find the required 'sub' claim)."
|
275 | );
|
276 | });
|
277 | });
|