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 = {}; 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 { 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 & { find: jest.Mock }; let knownGrant: DummyGrant; let oidcInteraction: Interaction; let provider: jest.Mocked; let providerFactory: jest.Mocked; let handler: ConsentHandler; beforeEach(async(): Promise => { 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 => { 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 => { 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 => { 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 => { oidcInteraction.session = undefined; await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction })) .rejects.toThrow(NotImplementedHttpError); }); it('throws a redirect error.', async(): Promise => { const operation = createPostJsonOperation({}); await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError); }); it('stores the requested scopes and claims in the grant.', async(): Promise => { 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 => { 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 => { 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 => { 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' ]); }); });