-
Notifications
You must be signed in to change notification settings - Fork 121
/
IdentityProviderFactory.test.ts
337 lines (294 loc) · 14.5 KB
/
IdentityProviderFactory.test.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
import { Readable } from 'stream';
import { exportJWK, generateKeyPair } from 'jose';
import type * as Koa from 'koa';
import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler';
import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory';
import type { JwkGenerator } from '../../../../src/identity/configuration/JwkGenerator';
import type {
ClientCredentials,
} from '../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler';
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { extractErrorTerms } from '../../../../src/util/errors/HttpErrorUtil';
import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError';
import type { errors, Configuration, KoaContextWithOIDC } from '../../../../templates/types/oidc-provider';
/* eslint-disable @typescript-eslint/naming-convention */
jest.mock('oidc-provider', (): any =>
jest.fn().mockImplementation((issuer: string, config: Configuration): any => ({ issuer, config, use: jest.fn() })));
const routes = {
authorization: '/foo/oidc/auth',
backchannel_authentication: '/foo/oidc/backchannel',
code_verification: '/foo/oidc/device',
device_authorization: '/foo/oidc/device/auth',
end_session: '/foo/oidc/session/end',
introspection: '/foo/oidc/token/introspection',
jwks: '/foo/oidc/jwks',
pushed_authorization_request: '/foo/oidc/request',
registration: '/foo/oidc/reg',
revocation: '/foo/oidc/token/revocation',
token: '/foo/oidc/token',
userinfo: '/foo/oidc/me',
};
describe('An IdentityProviderFactory', (): void => {
let jestWorkerId: string | undefined;
let nodeEnv: string | undefined;
let baseConfig: Configuration;
const baseUrl = 'http://example.com/foo/';
const oidcPath = '/oidc';
const webId = 'http://alice.example.com/card#me';
const redirectUrl = 'http://example.com/login/';
const oidcInteraction: Interaction = {} as any;
let ctx: KoaContextWithOIDC;
let interactionHandler: jest.Mocked<InteractionHandler>;
let adapterFactory: jest.Mocked<AdapterFactory>;
let storage: jest.Mocked<KeyValueStorage<string, any>>;
let jwkGenerator: jest.Mocked<JwkGenerator>;
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
let errorHandler: jest.Mocked<ErrorHandler>;
let responseWriter: jest.Mocked<ResponseWriter>;
let factory: IdentityProviderFactory;
beforeAll(async(): Promise<void> => {
// We need to fool the IDP factory into thinking we are not in a test run,
// otherwise we can't mock the oidc-provider library due to the workaround in the code there.
jestWorkerId = process.env.JEST_WORKER_ID;
nodeEnv = process.env.NODE_ENV;
delete process.env.JEST_WORKER_ID;
delete process.env.NODE_ENV;
});
afterAll(async(): Promise<void> => {
process.env.JEST_WORKER_ID = jestWorkerId;
process.env.NODE_ENV = nodeEnv;
});
beforeEach(async(): Promise<void> => {
// Disabling devInteractions to prevent warnings when testing the path
// where we use the actual library instead of a mock.
baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}, features: { devInteractions: { enabled: false }}};
ctx = {
method: 'GET',
req: Readable.from('data'),
res: {},
request: {
href: 'http://example.com/idp/',
query: {
client_id: 'CLIENT_ID',
redirect_uri: 'REDIRECT_URI',
},
},
accepts: jest.fn().mockReturnValue('type'),
} as any;
interactionHandler = {
handleSafe: jest.fn().mockRejectedValue(new FoundHttpError(redirectUrl)),
} as any;
adapterFactory = {
createStorageAdapter: jest.fn().mockReturnValue('adapter!'),
};
const map = new Map();
storage = {
get: jest.fn((id: string): any => map.get(id)),
set: jest.fn((id: string, value: any): any => map.set(id, value)),
} as any;
const { privateKey, publicKey } = await generateKeyPair('ES256');
jwkGenerator = {
alg: 'ES256',
getPrivateKey: jest.fn().mockResolvedValue({ ...await exportJWK(privateKey), alg: 'ES256' }),
getPublicKey: jest.fn().mockResolvedValue({ ...await exportJWK(publicKey), alg: 'ES256' }),
};
credentialStorage = {
get: jest.fn((id: string): any => map.get(id)),
set: jest.fn((id: string, value: any): any => map.set(id, value)),
} as any;
errorHandler = {
handleSafe: jest.fn().mockResolvedValue({ statusCode: 500 }),
} as any;
responseWriter = { handleSafe: jest.fn() } as any;
factory = new IdentityProviderFactory(baseConfig, {
adapterFactory,
baseUrl,
oidcPath,
interactionHandler,
storage,
jwkGenerator,
credentialStorage,
showStackTrace: true,
errorHandler,
responseWriter,
});
});
it('creates a correct configuration.', async(): Promise<void> => {
// This is the output of our mock function
const provider = await factory.getProvider() as any;
expect(provider.proxy).toBe(true);
const { issuer, config } = provider as { issuer: string; config: Configuration };
expect(issuer).toBe(baseUrl);
// Copies the base config
expect(config.claims).toEqual(baseConfig.claims);
(config.adapter as (name: string) => any)('test!');
expect(adapterFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!');
expect(config.cookies?.keys).toEqual([ expect.any(String) ]);
expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ alg: 'ES256' }) ]});
expect(config.routes).toEqual(routes);
expect(config.pkce?.methods).toEqual([ 'S256' ]);
expect((config.pkce!.required as any)()).toBe(true);
expect(config.clientDefaults?.id_token_signed_response_alg).toBe('ES256');
await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl);
let findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId);
expect(findResult?.accountId).toBe(webId);
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId, azp: 'clientId' });
findResult = await config.findAccount?.({ oidc: {}} as any, webId);
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId });
await expect((config.extraTokenClaims as any)({}, {})).resolves.toEqual({});
const client = { clientId: 'my_id' };
await expect((config.extraTokenClaims as any)({}, { client })).resolves.toEqual({});
await credentialStorage.set('my_id', { webId: 'http://example.com/foo', secret: 'my-secret' });
await expect((config.extraTokenClaims as any)({}, { client }))
.resolves.toEqual({ webid: 'http://example.com/foo' });
await expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' }))
.resolves.toEqual({ webid: webId });
expect(config.features?.resourceIndicators?.enabled).toBe(true);
expect((config.features?.resourceIndicators?.defaultResource as any)()).toBe('http://example.com/');
expect((config.features?.resourceIndicators?.getResourceServerInfo as any)()).toEqual({
scope: 'webid',
audience: 'solid',
accessTokenFormat: 'jwt',
jwt: { sign: { alg: 'ES256' }},
});
// Test the renderError function
const error = new Error('error!');
await expect((config.renderError as any)(ctx, {}, error)).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe)
.toHaveBeenLastCalledWith({ error, request: ctx.req });
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }});
});
it('errors if there is no valid interaction redirect.', async(): Promise<void> => {
interactionHandler.handleSafe.mockRejectedValueOnce(new Error('bad data'));
const provider = await factory.getProvider() as any;
const { config } = provider as { config: Configuration };
await expect((config.interactions?.url as any)(ctx, oidcInteraction)).rejects.toThrow('bad data');
interactionHandler.handleSafe.mockResolvedValueOnce(new BasicRepresentation());
await expect((config.interactions?.url as any)(ctx, oidcInteraction))
.rejects.toThrow('Could not correctly redirect for the given interaction.');
});
it('copies a field from the input config if values need to be added to it.', async(): Promise<void> => {
baseConfig.cookies = {
long: { signed: true },
};
factory = new IdentityProviderFactory(baseConfig, {
adapterFactory,
baseUrl,
oidcPath,
interactionHandler,
storage,
jwkGenerator,
credentialStorage,
showStackTrace: true,
errorHandler,
responseWriter,
});
const { config } = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
expect(config.cookies?.long?.signed).toBe(true);
});
it('caches the provider.', async(): Promise<void> => {
const result1 = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
const result2 = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
expect(result1).toBe(result2);
});
it('uses cached keys in case they already exist.', async(): Promise<void> => {
const result1 = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
// Create a new factory that is not cached yet
const factory2 = new IdentityProviderFactory(baseConfig, {
adapterFactory,
baseUrl,
oidcPath,
interactionHandler,
storage,
jwkGenerator,
credentialStorage,
showStackTrace: true,
errorHandler,
responseWriter,
});
const result2 = await factory2.getProvider() as unknown as { issuer: string; config: Configuration };
expect(result1.config.cookies).toEqual(result2.config.cookies);
expect(storage.get).toHaveBeenCalledTimes(2);
expect(storage.set).toHaveBeenCalledTimes(1);
expect(storage.set).toHaveBeenCalledWith('cookie-secret', result1.config.cookies?.keys);
});
it('updates errors if there is more information.', async(): Promise<void> => {
const provider = await factory.getProvider() as any;
const { config } = provider as { config: Configuration };
const error = new Error('bad data') as errors.OIDCProviderError;
error.error_description = 'more info';
error.error_detail = 'more details';
const oAuthError = new OAuthHttpError(error, error.name, 500, 'bad data - more info - more details');
await expect((config.renderError as any)(ctx, {}, error)).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe)
.toHaveBeenLastCalledWith({ error: oAuthError, request: ctx.req });
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }});
expect(oAuthError.message).toBe('bad data - more info - more details');
expect(oAuthError.stack).toContain('Error: bad data - more info - more details');
});
it('throws a specific error for unknown clients.', async(): Promise<void> => {
const provider = await factory.getProvider() as any;
const { config } = provider as { config: Configuration };
const error = new Error('invalid_client') as errors.OIDCProviderError;
error.error_description = 'client is invalid';
error.error_detail = 'client not found';
await expect((config.renderError as any)(ctx, {}, error)).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe)
.toHaveBeenLastCalledWith({ error: expect.objectContaining({
statusCode: 400,
name: 'BadRequestHttpError',
message: 'Unknown client, you might need to clear the local storage on the client.',
errorCode: 'E0003',
}),
request: ctx.req });
expect(extractErrorTerms(errorHandler.handleSafe.mock.calls[0][0].error.metadata)).toEqual({
client_id: 'CLIENT_ID',
redirect_uri: 'REDIRECT_URI',
});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }});
});
it('adds middleware to make the OIDC provider think the request wants HTML.', async(): Promise<void> => {
const provider = await factory.getProvider() as any;
const use = provider.use as jest.Mock<ReturnType<Koa['use']>, Parameters<Koa['use']>>;
expect(use).toHaveBeenCalledTimes(1);
const middleware = use.mock.calls[0][0];
const oldAccept = ctx.accepts;
const next = jest.fn();
await expect(middleware(ctx, next)).resolves.toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
expect(ctx.accepts('json', 'html')).toBe('html');
expect(oldAccept).toHaveBeenCalledTimes(0);
});
it('does not modify the context accepts function in other cases.', async(): Promise<void> => {
const provider = await factory.getProvider() as any;
const use = provider.use as jest.Mock<ReturnType<Koa['use']>, Parameters<Koa['use']>>;
expect(use).toHaveBeenCalledTimes(1);
const middleware = use.mock.calls[0][0];
const oldAccept = ctx.accepts;
const next = jest.fn();
await expect(middleware(ctx, next)).resolves.toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
expect(ctx.accepts('something')).toBe('type');
expect(oldAccept).toHaveBeenCalledTimes(1);
expect(oldAccept).toHaveBeenLastCalledWith('something');
});
it('avoids dynamic imports when testing with Jest.', async(): Promise<void> => {
// Reset the env variable, so we can test the path where the dynamic import is not used
process.env.JEST_WORKER_ID = jestWorkerId;
const provider = await factory.getProvider() as any;
// We don't define this in our mock
expect(provider.app).toBeDefined();
});
});