mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Return client information from consent handler
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
which enables passing custom variables to configurations and setting new default values.
|
||||
- The AppRunner functions have changed to require Components.js variables.
|
||||
This is important for anyone who starts the server from code.
|
||||
- When logging in, a consent screen will now provide information about the client.
|
||||
|
||||
### Configuration changes
|
||||
You might need to make changes to your v2 configuration if you use a custom config.
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { InteractionResults, KoaContextWithOIDC, UnknownObject } from 'oidc-provider';
|
||||
import type {
|
||||
AllClientMetadata,
|
||||
InteractionResults,
|
||||
KoaContextWithOIDC,
|
||||
UnknownObject,
|
||||
} from 'oidc-provider';
|
||||
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../http/representation/Representation';
|
||||
import { APPLICATION_JSON } from '../../util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
|
||||
import { FoundHttpError } from '../../util/errors/FoundHttpError';
|
||||
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
|
||||
@@ -11,6 +19,8 @@ type Grant = NonNullable<KoaContextWithOIDC['oidc']['entities']['Grant']>;
|
||||
|
||||
/**
|
||||
* Handles the OIDC consent prompts where the user confirms they want to log in for the given client.
|
||||
*
|
||||
* Returns all the relevant Client metadata on GET requests.
|
||||
*/
|
||||
export class ConsentHandler extends BaseInteractionHandler {
|
||||
private readonly providerFactory: ProviderFactory;
|
||||
@@ -30,6 +40,27 @@ export class ConsentHandler extends BaseInteractionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleGet(input: Required<InteractionHandlerInput>): Promise<Representation> {
|
||||
const { operation, oidcInteraction } = input;
|
||||
const provider = await this.providerFactory.getProvider();
|
||||
const client = await provider.Client.find(oidcInteraction.params.client_id as string);
|
||||
const metadata: AllClientMetadata = client?.metadata() ?? {};
|
||||
|
||||
// Only extract specific fields to prevent leaking information
|
||||
// Based on https://www.w3.org/ns/solid/oidc-context.jsonld
|
||||
const keys = [ 'client_id', 'client_uri', 'logo_uri', 'policy_uri',
|
||||
'client_name', 'contacts', 'grant_types', 'scope' ];
|
||||
|
||||
const jsonLd = Object.fromEntries(
|
||||
keys.filter((key): boolean => key in metadata)
|
||||
.map((key): [ string, unknown ] => [ key, metadata[key] ]),
|
||||
);
|
||||
jsonLd['@context'] = 'https://www.w3.org/ns/solid/oidc-context.jsonld';
|
||||
const json = { client: jsonLd };
|
||||
|
||||
return new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
|
||||
}
|
||||
|
||||
protected async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise<never> {
|
||||
const { remember } = await readJsonStream(operation.body.data);
|
||||
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
<h1>Authorize</h1>
|
||||
<p>You are authorizing an application to access your Pod.</p>
|
||||
<p>The following client wants to do authorized requests in your name:</p>
|
||||
<ul id="clientInfo">
|
||||
</ul>
|
||||
<form method="post" id="mainForm">
|
||||
<p class="error" id="error"></p>
|
||||
|
||||
<fieldset>
|
||||
<ol>
|
||||
<li class="checkbox">
|
||||
<label><input type="checkbox" name="remember" value="yes" checked>Stay logged in</label>
|
||||
<label><input type="checkbox" name="remember" value="yes" checked>Remember this client</label>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
<p class="actions"><button autofocus type="submit" name="submit">Continue</button></p>
|
||||
<p class="actions"><button autofocus type="submit" name="submit">Consent</button></p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const clientInfo = document.getElementById('clientInfo');
|
||||
function addClientInfo(text, value) {
|
||||
if (value) {
|
||||
const li = document.createElement('li');
|
||||
const strong = document.createElement('strong')
|
||||
strong.appendChild(document.createTextNode(value));
|
||||
li.appendChild(document.createTextNode(`${text}: `));
|
||||
li.appendChild(strong);
|
||||
clientInfo.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the client information
|
||||
(async() => {
|
||||
const res = await fetch('', { headers: { accept: 'application/json' } })
|
||||
const { client } = await res.json();
|
||||
addClientInfo('Name', client.client_name);
|
||||
addClientInfo('ID', client.client_id);
|
||||
})()
|
||||
|
||||
addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
|
||||
</script>
|
||||
|
||||
@@ -193,6 +193,12 @@ describe('A Solid server with IDP', (): void => {
|
||||
default_max_age: 3600,
|
||||
require_auth_time: true,
|
||||
};
|
||||
// This client will always reject requests since there is no valid redirect
|
||||
const badClientJson = {
|
||||
...clientJson,
|
||||
client_id: badClientId,
|
||||
redirect_uris: [],
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
let state: IdentityTestState;
|
||||
|
||||
@@ -205,13 +211,10 @@ describe('A Solid server with IDP', (): void => {
|
||||
body: JSON.stringify(clientJson),
|
||||
});
|
||||
|
||||
// This client will always reject requests since there is no valid redirect
|
||||
clientJson.client_id = badClientId;
|
||||
clientJson.redirect_uris = [];
|
||||
await fetch(badClientId, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/ld+json' },
|
||||
body: JSON.stringify(clientJson),
|
||||
body: JSON.stringify(badClientJson),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +227,14 @@ describe('A Solid server with IDP', (): void => {
|
||||
const res = await state.fetchIdp(url);
|
||||
expect(res.status).toBe(200);
|
||||
url = await state.login(url, email, password);
|
||||
|
||||
// Verify the client information the server discovered
|
||||
const consentRes = await state.fetchIdp(url, 'GET');
|
||||
expect(consentRes.status).toBe(200);
|
||||
const { client } = await consentRes.json();
|
||||
expect(client.client_id).toBe(clientJson.client_id);
|
||||
expect(client.client_name).toBe(clientJson.client_name);
|
||||
|
||||
await state.consent(url);
|
||||
expect(state.session.info?.webId).toBe(webId);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHand
|
||||
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';
|
||||
@@ -45,6 +46,10 @@ class DummyGrant {
|
||||
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;
|
||||
@@ -66,8 +71,12 @@ describe('A ConsentHandler', (): void => {
|
||||
grantFn = jest.fn((props): DummyGrant => new DummyGrant(props)) as any;
|
||||
grantFn.find = jest.fn((grantId: string): any => grantId ? knownGrant : undefined);
|
||||
provider = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
/* 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 = {
|
||||
@@ -89,6 +98,28 @@ describe('A ConsentHandler', (): void => {
|
||||
.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 }))
|
||||
|
||||
Reference in New Issue
Block a user