2022-02-21 10:04:57 +01:00

174 lines
6.9 KiB
TypeScript

import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory';
import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHandler';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../../../src/util/StreamUtil';
import { createPostJsonOperation } from './email-password/handler/Util';
const newGrantId = 'newGrantId';
class DummyGrant {
public accountId: string;
public clientId: string;
public readonly scopes: string[] = [];
public claims: string[] = [];
public readonly rejectedScopes: string[] = [];
public readonly resourceScopes: Record<string, string> = {};
public constructor(props: { accountId: string; clientId: string }) {
this.accountId = props.accountId;
this.clientId = props.clientId;
}
public rejectOIDCScope(scope: string): void {
this.rejectedScopes.push(scope);
}
public addOIDCScope(scope: string): void {
this.scopes.push(scope);
}
public addOIDCClaims(claims: string[]): void {
this.claims = claims;
}
public addResourceScope(resource: string, scope: string): void {
this.resourceScopes[resource] = scope;
}
public async save(): Promise<string> {
return newGrantId;
}
}
describe('A ConsentHandler', (): void => {
const accountId = 'http://example.com/id#me';
const clientId = 'clientId';
const clientMetadata = {
// eslint-disable-next-line @typescript-eslint/naming-convention
client_id: 'clientId',
};
let grantFn: jest.Mock<DummyGrant> & { find: jest.Mock<DummyGrant> };
let knownGrant: DummyGrant;
let oidcInteraction: Interaction;
let provider: jest.Mocked<Provider>;
let providerFactory: jest.Mocked<ProviderFactory>;
let handler: ConsentHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
session: { accountId },
// eslint-disable-next-line @typescript-eslint/naming-convention
params: { client_id: clientId },
prompt: { details: {}},
save: jest.fn(),
} as any;
knownGrant = new DummyGrant({ accountId, clientId });
grantFn = jest.fn((props): DummyGrant => new DummyGrant(props)) as any;
grantFn.find = jest.fn((grantId: string): any => grantId ? knownGrant : undefined);
provider = {
/* eslint-disable @typescript-eslint/naming-convention */
Grant: grantFn,
Client: {
find: (id: string): any => (id ? { metadata: jest.fn().mockReturnValue(clientMetadata) } : undefined),
},
/* eslint-enable @typescript-eslint/naming-convention */
} as any;
providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
handler = new ConsentHandler(providerFactory);
});
it('errors if no oidcInteraction is defined on POST requests.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error);
await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction }))
.resolves.toBeUndefined();
});
it('returns the client metadata on a GET request.', async(): Promise<void> => {
const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any;
const representation = await handler.handle({ operation, oidcInteraction });
await expect(readJsonStream(representation.data)).resolves.toEqual({
client: {
...clientMetadata,
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
},
});
});
it('returns an empty object if no client was found.', async(): Promise<void> => {
delete oidcInteraction.params.client_id;
const operation = { method: 'GET', target: { path: 'http://example.com/foo' }} as any;
const representation = await handler.handle({ operation, oidcInteraction });
await expect(readJsonStream(representation.data)).resolves.toEqual({
client: {
'@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld',
},
});
});
it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
oidcInteraction.session = undefined;
await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
.rejects.toThrow(NotImplementedHttpError);
});
it('throws a redirect error.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
});
it('stores the requested scopes and claims in the grant.', async(): Promise<void> => {
oidcInteraction.prompt.details = {
missingOIDCScope: [ 'scope1', 'scope2' ],
missingOIDCClaims: [ 'claim1', 'claim2' ],
missingResourceScopes: { resource: [ 'scope1', 'scope2' ]},
};
const operation = createPostJsonOperation({ remember: true });
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn.mock.results).toHaveLength(1);
expect(grantFn.mock.results[0].value.scopes).toEqual([ 'scope1 scope2' ]);
expect(grantFn.mock.results[0].value.claims).toEqual([ 'claim1', 'claim2' ]);
expect(grantFn.mock.results[0].value.resourceScopes).toEqual({ resource: 'scope1 scope2' });
expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([]);
});
it('creates a new Grant when needed.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn).toHaveBeenCalledTimes(1);
expect(grantFn).toHaveBeenLastCalledWith({ accountId, clientId });
expect(grantFn.find).toHaveBeenCalledTimes(0);
});
it('reuses existing Grant objects.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
oidcInteraction.grantId = '123456';
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn).toHaveBeenCalledTimes(0);
expect(grantFn.find).toHaveBeenCalledTimes(1);
expect(grantFn.find).toHaveBeenLastCalledWith('123456');
});
it('rejectes offline_access as scope if a user does not want to be remembered.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn.mock.results).toHaveLength(1);
expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([ 'offline_access' ]);
});
});