mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for client_credentials authentication
This commit is contained in:
@@ -87,6 +87,8 @@ module.exports = {
|
||||
'unicorn/prefer-at': 'off',
|
||||
// Does not make sense for more complex cases
|
||||
'unicorn/prefer-object-from-entries': 'off',
|
||||
// Only supported in Node v15
|
||||
'unicorn/prefer-string-replace-all' : 'off',
|
||||
// Can get ugly with large single statements
|
||||
'unicorn/prefer-ternary': 'off',
|
||||
'yield-star-spacing': [ 'error', 'after' ],
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
## v4.0.0
|
||||
### New features
|
||||
- The server can be started with a new parameter to automatically generate accounts and pods,
|
||||
for more info see [here](guides/seeding-pods.md).
|
||||
for more info see [here](documentation/seeding-pods.md).
|
||||
- It is now possible to automate authentication requests using Client Credentials,
|
||||
for more info see [here](documentation/client-credentials.md).
|
||||
- A new `RedirectingHttpHandler` class has been added which can be used to redirect certain URLs.
|
||||
- A new default configuration `config/https-file-cli.json`
|
||||
that can set the HTTPS parameters through the CLI has been added.
|
||||
@@ -23,6 +25,9 @@ The following changes are relevant for v3 custom configs that replaced certain f
|
||||
All storages there that were only relevant for 1 class have been moved to the config of that class.
|
||||
- Due to a parameter rename in `CombinedSettingsResolver`,
|
||||
`config/app/variables/resolver/resolver.json` has been updated.
|
||||
- The OIDC provider setup was changed to add client_credentials support.
|
||||
- `/identity/handler/adapter-factory/webid.json`
|
||||
- `/identity/handler/provider-factory/identity.json`
|
||||
|
||||
### Interface changes
|
||||
These changes are relevant if you wrote custom modules for the server that depend on existing interfaces.
|
||||
@@ -33,6 +38,7 @@ These changes are relevant if you wrote custom modules for the server that depen
|
||||
- Several `ModesExtractor`s `PermissionBasedAuthorizer` now take a `ResourceSet` as constructor parameter.
|
||||
- `RepresentationMetadata` no longer accepts strings for predicates in any of its functions.
|
||||
- `CombinedSettingsResolver` parameter `computers` has been renamed to `resolvers`.
|
||||
- `IdentityProviderFactory` requires an additional `credentialStorage` parameter.
|
||||
|
||||
## v3.0.0
|
||||
### New features
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
{
|
||||
"comment": "An adapter is responsible for storing all interaction metadata.",
|
||||
"@id": "urn:solid-server:default:IdpAdapterFactory",
|
||||
"@type": "WebIdAdapterFactory",
|
||||
"@type": "ClientCredentialsAdapterFactory",
|
||||
"storage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" },
|
||||
"source": {
|
||||
"@type": "ExpiringAdapterFactory",
|
||||
"storage": {
|
||||
"@type": "EncodingPathStorage",
|
||||
"relativePath": "/idp/adapter/",
|
||||
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
|
||||
"@type": "WebIdAdapterFactory",
|
||||
"converter": {"@id": "urn:solid-server:default:RepresentationConverter" },
|
||||
"source": {
|
||||
"@type": "ExpiringAdapterFactory",
|
||||
"storage": {
|
||||
"@type": "EncodingPathStorage",
|
||||
"relativePath": "/idp/adapter/",
|
||||
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
|
||||
"import": [
|
||||
"files-scs:config/identity/handler/interaction/routes/consent.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/credentials.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/forgot-password.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/index.json",
|
||||
"files-scs:config/identity/handler/interaction/routes/login.json",
|
||||
@@ -49,7 +50,8 @@
|
||||
{ "@id": "urn:solid-server:auth:password:LoginRouteHandler" },
|
||||
{ "@id": "urn:solid-server:auth:password:ConsentRouteHandler" },
|
||||
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" },
|
||||
{ "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" }
|
||||
{ "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" },
|
||||
{ "@id": "urn:solid-server:auth:password:CredentialsRouteHandler" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
50
config/identity/handler/interaction/routes/credentials.json
Normal file
50
config/identity/handler/interaction/routes/credentials.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Stores all client credential tokens.",
|
||||
"@id": "urn:solid-server:auth:password:CredentialsStorage",
|
||||
"@type": "EncodingPathStorage",
|
||||
"relativePath": "/idp/accounts/credentials/",
|
||||
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
|
||||
},
|
||||
{
|
||||
"comment": "Handles credential tokens. These can be used to automate clients. See documentation for more info.",
|
||||
"@id": "urn:solid-server:auth:password:CredentialsRouteHandler",
|
||||
"@type":"InteractionRouteHandler",
|
||||
"route": {
|
||||
"@id": "urn:solid-server:auth:password:CredentialsRoute",
|
||||
"@type": "RelativePathInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||
"relativePath": "/credentials/"
|
||||
},
|
||||
"source": {
|
||||
"@id": "urn:solid-server:auth:password:CredentialsHandler",
|
||||
"@type": "EmailPasswordAuthorizer",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
"source": {
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [
|
||||
{
|
||||
"@type": "CreateCredentialsHandler",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
"credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" }
|
||||
},
|
||||
{
|
||||
"@type": "DeleteCredentialsHandler",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||
"credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" }
|
||||
},
|
||||
{
|
||||
"@type": "ListCredentialsHandler",
|
||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
{
|
||||
"ControlHandler:_controls_key": "forgotPassword",
|
||||
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }
|
||||
},
|
||||
{
|
||||
"ControlHandler:_controls_key": "credentials",
|
||||
"ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:CredentialsRoute" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"args_oidcPath": "/.oidc",
|
||||
"args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" },
|
||||
"args_credentialStorage": { "@id": "urn:solid-server:auth:password:CredentialsStorage" },
|
||||
"args_storage": {
|
||||
"@type": "EncodingPathStorage",
|
||||
"relativePath": "/idp/keys/",
|
||||
@@ -30,6 +31,7 @@
|
||||
},
|
||||
"features": {
|
||||
"claimsParameter": { "enabled": true },
|
||||
"clientCredentials": { "enabled": true },
|
||||
"devInteractions": { "enabled": false },
|
||||
"dPoP": { "enabled": true, "ack": "draft-03" },
|
||||
"introspection": { "enabled": true },
|
||||
@@ -43,6 +45,7 @@
|
||||
"AccessToken": 3600,
|
||||
"AuthorizationCode": 600,
|
||||
"BackchannelAuthenticationRequest": 600,
|
||||
"ClientCredentials": 600,
|
||||
"DeviceCode": 600,
|
||||
"Grant": 1209600,
|
||||
"IdToken": 3600,
|
||||
|
||||
@@ -26,6 +26,7 @@ the [changelog](https://github.com/CommunitySolidServer/CommunitySolidServer/blo
|
||||
|
||||
* [Basic example HTTP requests](example-requests.md)
|
||||
* [How to use the Identity Provider](identity-provider.md)
|
||||
* [How to automate authentication](client-credentials.md)
|
||||
* [How to automatically seed pods on startup](seeding-pods.md)
|
||||
|
||||
## What the internals look like
|
||||
|
||||
103
documentation/client-credentials.md
Normal file
103
documentation/client-credentials.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Automating authentication with Client Credentials
|
||||
|
||||
One potential issue for scripts and other applications is that it requires user interaction to log in and authenticate.
|
||||
The CSS offers an alternative solution for such cases by making use of Client Credentials.
|
||||
Once you have created an account as described in the [Identity Provider section](dependency-injection.md),
|
||||
users can request a token that apps can use to authenticate without user input.
|
||||
|
||||
All requests to the client credentials API currently require you
|
||||
to send along the email and password of that account to identify yourself.
|
||||
This is a temporary solution until the server has more advanced account management,
|
||||
after which this API will change.
|
||||
|
||||
Below is example code of how to make use of these tokens.
|
||||
It makes use of several utility functions from the
|
||||
[Solid Authentication Client](https://github.com/inrupt/solid-client-authn-js).
|
||||
Note that the code below uses top-level `await`, which not all JavaScript engines support,
|
||||
so this should all be contained in an `async` function.
|
||||
|
||||
## Generating a token
|
||||
|
||||
The code below generates a token linked to your account and WebID.
|
||||
This only needs to be done once, afterwards this token can be used for all future requests.
|
||||
|
||||
```ts
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
// This assumes your server is started under http://localhost:3000/.
|
||||
// This URL can also be found by checking the controls in JSON responses when interacting with the IDP API,
|
||||
// as described in the Identity Provider section.
|
||||
const response = await fetch('http://localhost:3000/idp/credentials/', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
// The email/password fields are those of your account.
|
||||
// The name field will be used when generating the ID of your token.
|
||||
body: JSON.stringify({ email: 'my-email@example.com', password: 'my-account-password', name: 'my-token' }),
|
||||
});
|
||||
|
||||
// These are the identifier and secret of your token.
|
||||
// Store the secret somewhere safe as there is no way to request it again from the server!
|
||||
const { id, secret } = await response.json();
|
||||
```
|
||||
|
||||
## Requesting an Access token
|
||||
|
||||
The ID and secret combination generated above can be used to request an Access Token from the server.
|
||||
This Access Token is only valid for a certain amount of time, after which a new one needs to be requested.
|
||||
|
||||
```ts
|
||||
import { createDpopHeader, generateDpopKeyPair } from '@inrupt/solid-client-authn-core';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
// A key pair is needed for encryption.
|
||||
// This function from `solid-client-authn` generates such a pair for you.
|
||||
const dpopKey = await generateDpopKeyPair();
|
||||
|
||||
// These are the ID and secret generated in the previous step.
|
||||
// Both the ID and the secret need to be form-encoded.
|
||||
const authString = `${encodeURIComponent(id)}:${encodeURIComponent(secret)}`;
|
||||
// This URL can be found by looking at the "token_endpoint" field at
|
||||
// http://localhost:3000/.well-known/openid-configuration
|
||||
// if your server is hosted at http://localhost:3000/.
|
||||
const tokenUrl = 'http://localhost:3000/.oidc/token';
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// The header needs to be in base64 encoding.
|
||||
authorization: `Basic ${Buffer.from(authString).toString('base64')}`,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
dpop: await createDpopHeader(tokenUrl, 'POST', dpopKey),
|
||||
},
|
||||
body: 'grant_type=client_credentials&scope=webid',
|
||||
});
|
||||
|
||||
// This is the Access token that will be used to do an authenticated request to the server.
|
||||
// The JSON also contains an "expires_in" field in seconds,
|
||||
// which you can use to know when you need request a new Access token.
|
||||
const { access_token: accessToken } = await response.json();
|
||||
```
|
||||
|
||||
## Using the Access token to make an authenticated request
|
||||
|
||||
Once you have an Access token, you can use it for authenticated requests until it expires.
|
||||
|
||||
```ts
|
||||
import { buildAuthenticatedFetch } from '@inrupt/solid-client-authn-core';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
// The DPoP key needs to be the same key as the one used in the previous step.
|
||||
// The Access token is the one generated in the previous step.
|
||||
const authFetch = await buildAuthenticatedFetch(fetch, accessToken, { dpopKey });
|
||||
// authFetch can now be used as a standard fetch function that will authenticate as your WebID.
|
||||
// This request will do a simple GET for example.
|
||||
const response = await authFetch('http://localhost:3000/private');
|
||||
```
|
||||
|
||||
## Deleting a token
|
||||
|
||||
You can see all your existing tokens by doing a POST to `http://localhost:3000/idp/credentials/`
|
||||
with as body a JSON object containing your email and password.
|
||||
The response will be a JSON list containing all your tokens.
|
||||
|
||||
Deleting a token requires also doing a POST to the same URL,
|
||||
but adding a `delete` key to the JSON input object with as value the ID of the token you want to remove.
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -72,6 +72,7 @@
|
||||
"community-solid-server": "bin/server.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inrupt/solid-client-authn-core": "^1.11.7",
|
||||
"@inrupt/solid-client-authn-node": "^1.11.5",
|
||||
"@microsoft/tsdoc-config": "^0.15.2",
|
||||
"@tsconfig/node12": "^1.0.9",
|
||||
@@ -3733,9 +3734,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@inrupt/solid-client-authn-core": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz",
|
||||
"integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==",
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.7.tgz",
|
||||
"integrity": "sha512-PjrZ13tmFkamro/+JzE3jURmL7vwzHYUIq81pvn6kTYBa9C/2eHwnZbOZD2T1OplBSddKr9sJshISJJTQc5s6w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@inrupt/solid-common-vocab": "^1.0.0",
|
||||
@@ -17971,9 +17972,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@inrupt/solid-client-authn-core": {
|
||||
"version": "1.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.5.tgz",
|
||||
"integrity": "sha512-hsGKgv2SsTEo33V5t9crRs+RdKgdtxuIb3VoiGmaDXqLkd9rI/CZZkqnpwUskf4VuBN7Z3h9TKAFohRJMcvF7Q==",
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/@inrupt/solid-client-authn-core/-/solid-client-authn-core-1.11.7.tgz",
|
||||
"integrity": "sha512-PjrZ13tmFkamro/+JzE3jURmL7vwzHYUIq81pvn6kTYBa9C/2eHwnZbOZD2T1OplBSddKr9sJshISJJTQc5s6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@inrupt/solid-common-vocab": "^1.0.0",
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"yargs": "^17.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inrupt/solid-client-authn-core": "^1.11.5",
|
||||
"@inrupt/solid-client-authn-node": "^1.11.5",
|
||||
"@microsoft/tsdoc-config": "^0.15.2",
|
||||
"@tsconfig/node12": "^1.0.9",
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { JWK } from 'jose';
|
||||
import { exportJWK, generateKeyPair } from 'jose';
|
||||
import type { Account,
|
||||
Adapter,
|
||||
CanBePromise,
|
||||
Configuration,
|
||||
ErrorOut,
|
||||
KoaContextWithOIDC,
|
||||
@@ -21,6 +20,7 @@ import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
|
||||
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
|
||||
import { joinUrl } from '../../util/PathUtil';
|
||||
import type { ClientCredentials } from '../interaction/email-password/credentials/ClientCredentialsAdapterFactory';
|
||||
import type { InteractionHandler } from '../interaction/InteractionHandler';
|
||||
import type { AdapterFactory } from '../storage/AdapterFactory';
|
||||
import type { ProviderFactory } from './ProviderFactory';
|
||||
@@ -42,6 +42,10 @@ export interface IdentityProviderFactoryArgs {
|
||||
* The handler responsible for redirecting interaction requests to the correct URL.
|
||||
*/
|
||||
interactionHandler: InteractionHandler;
|
||||
/**
|
||||
* Storage containing the generated client credentials with their associated WebID.
|
||||
*/
|
||||
credentialStorage: KeyValueStorage<string, ClientCredentials>;
|
||||
/**
|
||||
* Storage used to store cookie and JWT keys so they can be re-used in case of multithreading.
|
||||
*/
|
||||
@@ -72,6 +76,7 @@ export class IdentityProviderFactory implements ProviderFactory {
|
||||
private readonly baseUrl!: string;
|
||||
private readonly oidcPath!: string;
|
||||
private readonly interactionHandler!: InteractionHandler;
|
||||
private readonly credentialStorage!: KeyValueStorage<string, ClientCredentials>;
|
||||
private readonly storage!: KeyValueStorage<string, unknown>;
|
||||
private readonly errorHandler!: ErrorHandler;
|
||||
private readonly responseWriter!: ResponseWriter;
|
||||
@@ -220,10 +225,10 @@ export class IdentityProviderFactory implements ProviderFactory {
|
||||
// Add extra claims in case an AccessToken is being issued.
|
||||
// Specifically this sets the required webid and client_id claims for the access token
|
||||
// See https://solid.github.io/solid-oidc/#resource-access-validation
|
||||
config.extraTokenClaims = (ctx, token): CanBePromise<UnknownObject> =>
|
||||
config.extraTokenClaims = async(ctx, token): Promise<UnknownObject> =>
|
||||
this.isAccessToken(token) ?
|
||||
{ webid: token.accountId } :
|
||||
{};
|
||||
{ webid: token.client && (await this.credentialStorage.get(token.client.clientId))?.webId };
|
||||
|
||||
config.features = {
|
||||
...config.features,
|
||||
@@ -239,8 +244,8 @@ export class IdentityProviderFactory implements ProviderFactory {
|
||||
// See https://github.com/panva/node-oidc-provider/discussions/959#discussioncomment-524757
|
||||
getResourceServerInfo: (): ResourceServer => ({
|
||||
// The scopes of the Resource Server.
|
||||
// Since this is irrelevant at the moment, an empty string is fine.
|
||||
scope: '',
|
||||
// These get checked when requesting client credentials.
|
||||
scope: 'webid',
|
||||
audience: 'solid',
|
||||
accessTokenFormat: 'jwt',
|
||||
jwt: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InteractionHandlerInput } from './InteractionHandler';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
import type { InteractionRoute } from './routing/InteractionRoute';
|
||||
|
||||
const INTERNAL_API_VERSION = '0.3';
|
||||
const INTERNAL_API_VERSION = '0.4';
|
||||
|
||||
/**
|
||||
* Adds `controls` and `apiVersion` fields to the output of its source handler,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AdapterPayload, Adapter } from 'oidc-provider';
|
||||
import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage';
|
||||
import type { AdapterFactory } from '../../../storage/AdapterFactory';
|
||||
import { PassthroughAdapterFactory, PassthroughAdapter } from '../../../storage/PassthroughAdapterFactory';
|
||||
|
||||
export interface ClientCredentials {
|
||||
secret: string;
|
||||
webId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link PassthroughAdapter} that overrides the `find` function
|
||||
* by checking if there are stored client credentials for the given ID
|
||||
* if no payload is found in the source.
|
||||
*/
|
||||
export class ClientCredentialsAdapter extends PassthroughAdapter {
|
||||
private readonly storage: KeyValueStorage<string, ClientCredentials>;
|
||||
|
||||
public constructor(name: string, source: Adapter, storage: KeyValueStorage<string, ClientCredentials>) {
|
||||
super(name, source);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
public async find(id: string): Promise<AdapterPayload | void | undefined> {
|
||||
let payload = await this.source.find(id);
|
||||
|
||||
if (!payload && this.name === 'Client') {
|
||||
const credentials = await this.storage.get(id);
|
||||
if (credentials) {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
payload = {
|
||||
client_id: id,
|
||||
client_secret: credentials.secret,
|
||||
grant_types: [ 'client_credentials' ],
|
||||
redirect_uris: [],
|
||||
response_types: [],
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientCredentialsAdapterFactory extends PassthroughAdapterFactory {
|
||||
private readonly storage: KeyValueStorage<string, ClientCredentials>;
|
||||
|
||||
public constructor(source: AdapterFactory, storage: KeyValueStorage<string, ClientCredentials>) {
|
||||
super(source);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
public createStorageAdapter(name: string): Adapter {
|
||||
return new ClientCredentialsAdapter(name, this.source.createStorageAdapter(name), this.storage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { v4 } from 'uuid';
|
||||
import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../http/representation/Representation';
|
||||
import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage';
|
||||
import { APPLICATION_JSON } from '../../../../util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
|
||||
import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import type { ClientCredentials } from './ClientCredentialsAdapterFactory';
|
||||
import type { CredentialsHandlerInput } from './CredentialsHandler';
|
||||
import { CredentialsHandler } from './CredentialsHandler';
|
||||
|
||||
/**
|
||||
* Handles the creation of credential tokens.
|
||||
* Requires a `name` field in the input JSON body,
|
||||
* that will be used to generate the ID token.
|
||||
*/
|
||||
export class CreateCredentialsHandler extends CredentialsHandler {
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly credentialStorage: KeyValueStorage<string, ClientCredentials>;
|
||||
|
||||
public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage<string, ClientCredentials>) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.credentialStorage = credentialStorage;
|
||||
}
|
||||
|
||||
public async canHandle({ body }: CredentialsHandlerInput): Promise<void> {
|
||||
if (typeof body.name !== 'string') {
|
||||
throw new NotImplementedHttpError();
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ operation, body: { webId, name }}: CredentialsHandlerInput): Promise<Representation> {
|
||||
const settings = await this.accountStore.getSettings(webId);
|
||||
|
||||
if (!settings.useIdp) {
|
||||
throw new BadRequestHttpError('This server is not an identity provider for this account.');
|
||||
}
|
||||
|
||||
const id = `${(name as string).replace(/\W/gu, '-')}_${v4()}`;
|
||||
const secret = randomBytes(64).toString('hex');
|
||||
|
||||
// Store the credentials, and point to them from the account
|
||||
settings.clientCredentials = settings.clientCredentials ?? [];
|
||||
settings.clientCredentials.push(id);
|
||||
await this.accountStore.updateSettings(webId, settings);
|
||||
await this.credentialStorage.set(id, { secret, webId });
|
||||
|
||||
const response = { id, secret };
|
||||
return new BasicRepresentation(JSON.stringify(response), operation.target, APPLICATION_JSON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Operation } from '../../../../http/Operation';
|
||||
import type { Representation } from '../../../../http/representation/Representation';
|
||||
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
|
||||
|
||||
export interface CredentialsHandlerBody extends Record<string, unknown> {
|
||||
email: string;
|
||||
webId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `body` is the parsed JSON from `operation.body.data` with the WebID of the account having been added.
|
||||
* This means that the data stream in the Operation can not be read again.
|
||||
*/
|
||||
export interface CredentialsHandlerInput {
|
||||
operation: Operation;
|
||||
body: CredentialsHandlerBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a request after the user has been authenticated
|
||||
* by providing a valid email/password combination in the JSON body.
|
||||
*/
|
||||
export abstract class CredentialsHandler extends AsyncHandler<CredentialsHandlerInput, Representation> { }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../http/representation/Representation';
|
||||
import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage';
|
||||
import { APPLICATION_JSON } from '../../../../util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
|
||||
import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import type { ClientCredentials } from './ClientCredentialsAdapterFactory';
|
||||
import type { CredentialsHandlerInput } from './CredentialsHandler';
|
||||
import { CredentialsHandler } from './CredentialsHandler';
|
||||
|
||||
/**
|
||||
* Handles the deletion of credential tokens.
|
||||
* Expects the JSON body to have a `delete` field with as value the ID of the token to be deleted.
|
||||
* This should be replaced to be an actual DELETE request once the API supports it.
|
||||
*/
|
||||
export class DeleteCredentialsHandler extends CredentialsHandler {
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly credentialStorage: KeyValueStorage<string, ClientCredentials>;
|
||||
|
||||
public constructor(accountStore: AccountStore, credentialStorage: KeyValueStorage<string, ClientCredentials>) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.credentialStorage = credentialStorage;
|
||||
}
|
||||
|
||||
public async canHandle({ body }: CredentialsHandlerInput): Promise<void> {
|
||||
if (typeof body.delete !== 'string') {
|
||||
throw new NotImplementedHttpError();
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ operation, body }: CredentialsHandlerInput): Promise<Representation> {
|
||||
const id = body.delete as string;
|
||||
const settings = await this.accountStore.getSettings(body.webId);
|
||||
settings.clientCredentials = settings.clientCredentials ?? [];
|
||||
const idx = settings.clientCredentials.indexOf(id);
|
||||
if (idx < 0) {
|
||||
throw new BadRequestHttpError('No credential with this ID exists for this account.');
|
||||
}
|
||||
|
||||
await this.credentialStorage.delete(id);
|
||||
settings.clientCredentials.splice(idx, 1);
|
||||
await this.accountStore.updateSettings(body.webId, settings);
|
||||
return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'assert';
|
||||
import type { Representation } from '../../../../http/representation/Representation';
|
||||
import { MethodNotAllowedHttpError } from '../../../../util/errors/MethodNotAllowedHttpError';
|
||||
import { readJsonStream } from '../../../../util/StreamUtil';
|
||||
import type { InteractionHandlerInput } from '../../InteractionHandler';
|
||||
import { InteractionHandler } from '../../InteractionHandler';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import type { CredentialsHandler } from './CredentialsHandler';
|
||||
|
||||
/**
|
||||
* Authenticates a user by the email/password in a JSON POST body.
|
||||
* Passes the body and the WebID associated with that account to the source handler.
|
||||
*/
|
||||
export class EmailPasswordAuthorizer extends InteractionHandler {
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly source: CredentialsHandler;
|
||||
|
||||
public constructor(accountStore: AccountStore, source: CredentialsHandler) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
|
||||
if (operation.method !== 'POST') {
|
||||
throw new MethodNotAllowedHttpError([ operation.method ], 'Only POST requests are supported.');
|
||||
}
|
||||
const json = await readJsonStream(operation.body.data);
|
||||
const { email, password } = json;
|
||||
assert(typeof email === 'string' && email.length > 0, 'Email required');
|
||||
assert(typeof password === 'string' && password.length > 0, 'Password required');
|
||||
const webId = await this.accountStore.authenticate(email, password);
|
||||
// Password no longer needed from this point on
|
||||
delete json.password;
|
||||
return this.source.handleSafe({ operation, body: { ...json, email, webId }});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../http/representation/Representation';
|
||||
import { APPLICATION_JSON } from '../../../../util/ContentTypes';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import type { CredentialsHandlerInput } from './CredentialsHandler';
|
||||
import { CredentialsHandler } from './CredentialsHandler';
|
||||
|
||||
/**
|
||||
* Returns a list of all credential tokens associated with this account.
|
||||
* Note that this only returns the ID tokens, not the associated secrets.
|
||||
*/
|
||||
export class ListCredentialsHandler extends CredentialsHandler {
|
||||
private readonly accountStore: AccountStore;
|
||||
|
||||
public constructor(accountStore: AccountStore) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
}
|
||||
|
||||
public async handle({ operation, body: { webId }}: CredentialsHandlerInput): Promise<Representation> {
|
||||
const credentials = (await this.accountStore.getSettings(webId)).clientCredentials ?? [];
|
||||
return new BasicRepresentation(JSON.stringify(credentials), operation.target, APPLICATION_JSON);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export interface AccountSettings {
|
||||
* The base URL of the pod associated with this account, if there is one.
|
||||
*/
|
||||
podBaseUrl?: string;
|
||||
/**
|
||||
* All credential tokens associated with this account.
|
||||
*/
|
||||
clientCredentials?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -193,6 +193,7 @@ export class RegistrationManager {
|
||||
const settings: AccountSettings = {
|
||||
useIdp: input.register,
|
||||
podBaseUrl: podBaseUrl?.path,
|
||||
clientCredentials: [],
|
||||
};
|
||||
await this.accountStore.create(input.email, input.webId!, input.password, settings);
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Adapter } from 'oidc-provider';
|
||||
|
||||
/**
|
||||
* A factory that generates a StorageAdapter to be used
|
||||
* by the IDP to persist information.
|
||||
* A factory that generates an `Adapter` to be used by the IDP to persist information.
|
||||
*
|
||||
* The `oidc-provider` library will call the relevant functions when it needs to find/create/delete metadata.
|
||||
* For a full explanation of how these functions work and what is expected,
|
||||
* have a look at https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js
|
||||
*/
|
||||
export interface AdapterFactory {
|
||||
createStorageAdapter: (name: string) => Adapter;
|
||||
|
||||
57
src/identity/storage/PassthroughAdapterFactory.ts
Normal file
57
src/identity/storage/PassthroughAdapterFactory.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Adapter, AdapterPayload } from 'oidc-provider';
|
||||
import type { AdapterFactory } from './AdapterFactory';
|
||||
|
||||
/**
|
||||
* OIDC Adapter that calls the corresponding functions of the source Adapter.
|
||||
* Can be extended by adapters that do not want to override all functions
|
||||
* by implementing a decorator pattern.
|
||||
*/
|
||||
export class PassthroughAdapter implements Adapter {
|
||||
protected readonly name: string;
|
||||
protected readonly source: Adapter;
|
||||
|
||||
public constructor(name: string, source: Adapter) {
|
||||
this.name = name;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void | undefined> {
|
||||
return this.source.upsert(id, payload, expiresIn);
|
||||
}
|
||||
|
||||
public async find(id: string): Promise<AdapterPayload | void | undefined> {
|
||||
return this.source.find(id);
|
||||
}
|
||||
|
||||
public async findByUserCode(userCode: string): Promise<AdapterPayload | void | undefined> {
|
||||
return this.source.findByUserCode(userCode);
|
||||
}
|
||||
|
||||
public async findByUid(uid: string): Promise<AdapterPayload | void | undefined> {
|
||||
return this.source.findByUid(uid);
|
||||
}
|
||||
|
||||
public async consume(id: string): Promise<void | undefined> {
|
||||
return this.source.consume(id);
|
||||
}
|
||||
|
||||
public async destroy(id: string): Promise<void | undefined> {
|
||||
return this.source.destroy(id);
|
||||
}
|
||||
|
||||
public async revokeByGrantId(grantId: string): Promise<void | undefined> {
|
||||
return this.source.revokeByGrantId(grantId);
|
||||
}
|
||||
}
|
||||
|
||||
export class PassthroughAdapterFactory implements AdapterFactory {
|
||||
protected readonly source: AdapterFactory;
|
||||
|
||||
public constructor(source: AdapterFactory) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public createStorageAdapter(name: string): Adapter {
|
||||
return this.source.createStorageAdapter(name);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { responseToDataset } from '../../util/FetchUtil';
|
||||
import { hasScheme } from '../../util/HeaderUtil';
|
||||
import { OIDC } from '../../util/Vocabularies';
|
||||
import type { AdapterFactory } from './AdapterFactory';
|
||||
import { PassthroughAdapter, PassthroughAdapterFactory } from './PassthroughAdapterFactory';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
@@ -19,23 +20,16 @@ import type { AdapterFactory } from './AdapterFactory';
|
||||
* If a valid `solid:oidcRegistration` triple is found there,
|
||||
* that data will be returned instead.
|
||||
*/
|
||||
export class WebIdAdapter implements Adapter {
|
||||
export class WebIdAdapter extends PassthroughAdapter {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly name: string;
|
||||
private readonly source: Adapter;
|
||||
private readonly converter: RepresentationConverter;
|
||||
|
||||
public constructor(name: string, source: Adapter, converter: RepresentationConverter) {
|
||||
this.name = name;
|
||||
this.source = source;
|
||||
super(name, source);
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
public async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||
return this.source.upsert(id, payload, expiresIn);
|
||||
}
|
||||
|
||||
public async find(id: string): Promise<AdapterPayload | void> {
|
||||
let payload = await this.source.find(id);
|
||||
|
||||
@@ -108,34 +102,13 @@ export class WebIdAdapter implements Adapter {
|
||||
redirect_uris: redirectUris,
|
||||
};
|
||||
}
|
||||
|
||||
public async findByUserCode(userCode: string): Promise<AdapterPayload | void> {
|
||||
return this.source.findByUserCode(userCode);
|
||||
}
|
||||
|
||||
public async findByUid(uid: string): Promise<AdapterPayload | void> {
|
||||
return this.source.findByUid(uid);
|
||||
}
|
||||
|
||||
public async destroy(id: string): Promise<void> {
|
||||
return this.source.destroy(id);
|
||||
}
|
||||
|
||||
public async revokeByGrantId(grantId: string): Promise<void> {
|
||||
return this.source.revokeByGrantId(grantId);
|
||||
}
|
||||
|
||||
public async consume(id: string): Promise<void> {
|
||||
return this.source.consume(id);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebIdAdapterFactory implements AdapterFactory {
|
||||
private readonly source: AdapterFactory;
|
||||
export class WebIdAdapterFactory extends PassthroughAdapterFactory {
|
||||
private readonly converter: RepresentationConverter;
|
||||
|
||||
public constructor(source: AdapterFactory, converter: RepresentationConverter) {
|
||||
this.source = source;
|
||||
super(source);
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,14 @@ export * from './http/UnsecureWebSocketsProtocol';
|
||||
export * from './identity/configuration/IdentityProviderFactory';
|
||||
export * from './identity/configuration/ProviderFactory';
|
||||
|
||||
// Identity/Interaction/Email-Password/Credentials
|
||||
export * from './identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
|
||||
export * from './identity/interaction/email-password/credentials/EmailPasswordAuthorizer';
|
||||
export * from './identity/interaction/email-password/credentials/CreateCredentialsHandler';
|
||||
export * from './identity/interaction/email-password/credentials/CredentialsHandler';
|
||||
export * from './identity/interaction/email-password/credentials/DeleteCredentialsHandler';
|
||||
export * from './identity/interaction/email-password/credentials/ListCredentialsHandler';
|
||||
|
||||
// Identity/Interaction/Email-Password/Handler
|
||||
export * from './identity/interaction/email-password/handler/ForgotPasswordHandler';
|
||||
export * from './identity/interaction/email-password/handler/LoginHandler';
|
||||
@@ -172,6 +180,7 @@ export * from './identity/ownership/TokenOwnershipValidator';
|
||||
// Identity/Storage
|
||||
export * from './identity/storage/AdapterFactory';
|
||||
export * from './identity/storage/ExpiringAdapterFactory';
|
||||
export * from './identity/storage/PassthroughAdapterFactory';
|
||||
export * from './identity/storage/WebIdAdapterFactory';
|
||||
|
||||
// Identity
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { stringify } from 'querystring';
|
||||
import { URL } from 'url';
|
||||
import type { KeyPair } from '@inrupt/solid-client-authn-core';
|
||||
import {
|
||||
buildAuthenticatedFetch,
|
||||
createDpopHeader,
|
||||
generateDpopKeyPair,
|
||||
} from '@inrupt/solid-client-authn-core';
|
||||
import { load } from 'cheerio';
|
||||
import type { Response } from 'cross-fetch';
|
||||
import { fetch } from 'cross-fetch';
|
||||
import type { App } from '../../src/init/App';
|
||||
import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes';
|
||||
import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes';
|
||||
import { joinUrl } from '../../src/util/PathUtil';
|
||||
import { getPort } from '../util/Util';
|
||||
import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config';
|
||||
@@ -37,6 +43,7 @@ async function postForm(url: string, formBody: string): Promise<Response> {
|
||||
describe('A Solid server with IDP', (): void => {
|
||||
let app: App;
|
||||
const redirectUrl = 'http://mockedredirect/';
|
||||
const container = new URL('secret/', baseUrl).href;
|
||||
const oidcIssuer = baseUrl;
|
||||
const card = joinUrl(baseUrl, 'profile/card');
|
||||
const webId = `${card}#me`;
|
||||
@@ -61,11 +68,26 @@ describe('A Solid server with IDP', (): void => {
|
||||
await app.start();
|
||||
|
||||
// Create a simple webId
|
||||
const turtle = `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${baseUrl}> .`;
|
||||
const webIdTurtle = `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${baseUrl}> .`;
|
||||
await fetch(card, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'text/turtle' },
|
||||
body: turtle,
|
||||
body: webIdTurtle,
|
||||
});
|
||||
|
||||
// Create container where only webId can write
|
||||
const aclTurtle = `
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
<#owner> a acl:Authorization;
|
||||
acl:agent <${webId}>;
|
||||
acl:accessTo <./>;
|
||||
acl:default <./>;
|
||||
acl:mode acl:Read, acl:Write, acl:Control.
|
||||
`;
|
||||
await fetch(`${container}.acl`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'text/turtle' },
|
||||
body: aclTurtle,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,25 +134,9 @@ describe('A Solid server with IDP', (): void => {
|
||||
|
||||
describe('authenticating', (): void => {
|
||||
let state: IdentityTestState;
|
||||
const container = new URL('secret/', baseUrl).href;
|
||||
|
||||
beforeAll(async(): Promise<void> => {
|
||||
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
|
||||
|
||||
// Create container where only webId can write
|
||||
const turtle = `
|
||||
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||
<#owner> a acl:Authorization;
|
||||
acl:agent <${webId}>;
|
||||
acl:accessTo <./>;
|
||||
acl:default <./>;
|
||||
acl:mode acl:Read, acl:Write, acl:Control.
|
||||
`;
|
||||
await fetch(`${container}.acl`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'text/turtle' },
|
||||
body: turtle,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async(): Promise<void> => {
|
||||
@@ -262,6 +268,97 @@ describe('A Solid server with IDP', (): void => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('using client_credentials', (): void => {
|
||||
const credentialsUrl = joinUrl(baseUrl, '/idp/credentials/');
|
||||
const tokenUrl = joinUrl(baseUrl, '.oidc/token');
|
||||
let dpopKey: KeyPair;
|
||||
let id: string | undefined;
|
||||
let secret: string | undefined;
|
||||
let accessToken: string | undefined;
|
||||
|
||||
beforeAll(async(): Promise<void> => {
|
||||
dpopKey = await generateDpopKeyPair();
|
||||
});
|
||||
|
||||
it('can request a credentials token.', async(): Promise<void> => {
|
||||
const res = await fetch(credentialsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': APPLICATION_JSON,
|
||||
},
|
||||
body: JSON.stringify({ email, password, name: 'token' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
({ id, secret } = await res.json());
|
||||
expect(typeof id).toBe('string');
|
||||
expect(typeof secret).toBe('string');
|
||||
expect(id).toMatch(/^token/u);
|
||||
});
|
||||
|
||||
it('can request an access token using the credentials.', async(): Promise<void> => {
|
||||
const dpopHeader = await createDpopHeader(tokenUrl, 'POST', dpopKey);
|
||||
const authString = `${encodeURIComponent(id!)}:${encodeURIComponent(secret!)}`;
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from(authString).toString('base64')}`,
|
||||
'content-type': APPLICATION_X_WWW_FORM_URLENCODED,
|
||||
dpop: dpopHeader,
|
||||
},
|
||||
body: 'grant_type=client_credentials&scope=webid',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
({ access_token: accessToken } = json);
|
||||
expect(typeof accessToken).toBe('string');
|
||||
});
|
||||
|
||||
it('can use the generated access token to do an authenticated call.', async(): Promise<void> => {
|
||||
const authFetch = await buildAuthenticatedFetch(fetch, accessToken!, { dpopKey });
|
||||
let res = await fetch(container);
|
||||
expect(res.status).toBe(401);
|
||||
res = await authFetch(container);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('can see all credentials.', async(): Promise<void> => {
|
||||
const res = await fetch(credentialsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': APPLICATION_JSON,
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual([ id ]);
|
||||
});
|
||||
|
||||
it('can delete credentials.', async(): Promise<void> => {
|
||||
let res = await fetch(credentialsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': APPLICATION_JSON,
|
||||
},
|
||||
body: JSON.stringify({ email, password, delete: id }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Client_credentials call should fail now
|
||||
const dpopHeader = await createDpopHeader(tokenUrl, 'POST', dpopKey);
|
||||
const authString = `${encodeURIComponent(id!)}:${encodeURIComponent(secret!)}`;
|
||||
res = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Basic ${Buffer.from(authString).toString('base64')}`,
|
||||
'content-type': APPLICATION_X_WWW_FORM_URLENCODED,
|
||||
dpop: dpopHeader,
|
||||
},
|
||||
body: 'grant_type=client_credentials&scope=webid',
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetting password', (): void => {
|
||||
let nextUrl: string;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
settings = {
|
||||
useIdp: true,
|
||||
podBaseUrl,
|
||||
clientCredentials: [],
|
||||
};
|
||||
|
||||
accountStore = {
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('A ControlHandler', (): void => {
|
||||
const result = await handler.handle(input);
|
||||
await expect(readJsonStream(result.data)).resolves.toEqual({
|
||||
data: 'data',
|
||||
apiVersion: '0.3',
|
||||
apiVersion: '0.4',
|
||||
controls: {
|
||||
login: 'http://example.com/login/',
|
||||
register: 'http://example.com/register/',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandle
|
||||
import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter';
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory';
|
||||
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 { HttpResponse } from '../../../../src/server/HttpResponse';
|
||||
@@ -40,6 +43,7 @@ describe('An IdentityProviderFactory', (): void => {
|
||||
let interactionHandler: jest.Mocked<InteractionHandler>;
|
||||
let adapterFactory: jest.Mocked<AdapterFactory>;
|
||||
let storage: jest.Mocked<KeyValueStorage<string, any>>;
|
||||
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
|
||||
let errorHandler: jest.Mocked<ErrorHandler>;
|
||||
let responseWriter: jest.Mocked<ResponseWriter>;
|
||||
let factory: IdentityProviderFactory;
|
||||
@@ -68,6 +72,11 @@ describe('An IdentityProviderFactory', (): void => {
|
||||
set: jest.fn((id: string, value: any): any => map.set(id, value)),
|
||||
} as any;
|
||||
|
||||
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;
|
||||
@@ -80,6 +89,7 @@ describe('An IdentityProviderFactory', (): void => {
|
||||
oidcPath,
|
||||
interactionHandler,
|
||||
storage,
|
||||
credentialStorage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
});
|
||||
@@ -114,14 +124,19 @@ describe('An IdentityProviderFactory', (): void => {
|
||||
findResult = await config.findAccount?.({ oidc: {}} as any, webId);
|
||||
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId });
|
||||
|
||||
expect((config.extraTokenClaims as any)({}, {})).toEqual({});
|
||||
expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' }))
|
||||
.toEqual({ 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: '',
|
||||
scope: 'webid',
|
||||
audience: 'solid',
|
||||
accessTokenFormat: 'jwt',
|
||||
jwt: { sign: { alg: 'ES256' }},
|
||||
@@ -158,6 +173,7 @@ describe('An IdentityProviderFactory', (): void => {
|
||||
oidcPath,
|
||||
interactionHandler,
|
||||
storage,
|
||||
credentialStorage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
});
|
||||
@@ -180,6 +196,7 @@ describe('An IdentityProviderFactory', (): void => {
|
||||
oidcPath,
|
||||
interactionHandler,
|
||||
storage,
|
||||
credentialStorage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Adapter } from 'oidc-provider';
|
||||
import {
|
||||
ClientCredentialsAdapter,
|
||||
ClientCredentialsAdapterFactory,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
|
||||
import type {
|
||||
ClientCredentials,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
|
||||
import type { AdapterFactory } from '../../../../../../src/identity/storage/AdapterFactory';
|
||||
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
|
||||
describe('A ClientCredentialsAdapterFactory', (): void => {
|
||||
let storage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
|
||||
let sourceAdapter: jest.Mocked<Adapter>;
|
||||
let sourceFactory: jest.Mocked<AdapterFactory>;
|
||||
let adapter: ClientCredentialsAdapter;
|
||||
let factory: ClientCredentialsAdapterFactory;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
storage = {
|
||||
get: jest.fn(),
|
||||
} as any;
|
||||
|
||||
sourceAdapter = {
|
||||
find: jest.fn(),
|
||||
} as any;
|
||||
|
||||
sourceFactory = {
|
||||
createStorageAdapter: jest.fn().mockReturnValue(sourceAdapter),
|
||||
};
|
||||
|
||||
adapter = new ClientCredentialsAdapter('Client', sourceAdapter, storage);
|
||||
factory = new ClientCredentialsAdapterFactory(sourceFactory, storage);
|
||||
});
|
||||
|
||||
it('calls the source factory when creating a new Adapter.', async(): Promise<void> => {
|
||||
expect(factory.createStorageAdapter('Name')).toBeInstanceOf(ClientCredentialsAdapter);
|
||||
expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Name');
|
||||
});
|
||||
|
||||
it('returns the result from the source.', async(): Promise<void> => {
|
||||
sourceAdapter.find.mockResolvedValue({ payload: 'payload' });
|
||||
await expect(adapter.find('id')).resolves.toEqual({ payload: 'payload' });
|
||||
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
|
||||
expect(storage.get).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('tries to find a matching client credentials token if no result was found.', async(): Promise<void> => {
|
||||
await expect(adapter.find('id')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
|
||||
expect(storage.get).toHaveBeenCalledTimes(1);
|
||||
expect(storage.get).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
|
||||
it('returns valid client_credentials Client metadata if a matching token was found.', async(): Promise<void> => {
|
||||
storage.get.mockResolvedValue({ secret: 'super_secret', webId: 'http://example.com/foo#me' });
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
await expect(adapter.find('id')).resolves.toEqual({
|
||||
client_id: 'id',
|
||||
client_secret: 'super_secret',
|
||||
grant_types: [ 'client_credentials' ],
|
||||
redirect_uris: [],
|
||||
response_types: [],
|
||||
});
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
|
||||
expect(storage.get).toHaveBeenCalledTimes(1);
|
||||
expect(storage.get).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Operation } from '../../../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
|
||||
import type {
|
||||
ClientCredentials,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
|
||||
import {
|
||||
CreateCredentialsHandler,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/CreateCredentialsHandler';
|
||||
import type {
|
||||
CredentialsHandlerBody,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
|
||||
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
|
||||
import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError';
|
||||
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
|
||||
|
||||
describe('A CreateCredentialsHandler', (): void => {
|
||||
let operation: Operation;
|
||||
let body: CredentialsHandlerBody;
|
||||
let accountStore: jest.Mocked<AccountStore>;
|
||||
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
|
||||
let handler: CreateCredentialsHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
operation = {
|
||||
method: 'POST',
|
||||
body: new BasicRepresentation(),
|
||||
target: { path: 'http://example.com/foo' },
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
body = {
|
||||
email: 'test@example.com',
|
||||
webId: 'http://example.com/foo#me',
|
||||
name: 'token',
|
||||
};
|
||||
|
||||
accountStore = {
|
||||
getSettings: jest.fn().mockResolvedValue({ useIdp: true, clientCredentials: []}),
|
||||
updateSettings: jest.fn(),
|
||||
} as any;
|
||||
|
||||
credentialStorage = {
|
||||
set: jest.fn(),
|
||||
} as any;
|
||||
|
||||
handler = new CreateCredentialsHandler(accountStore, credentialStorage);
|
||||
});
|
||||
|
||||
it('only supports bodies with a name entry.', async(): Promise<void> => {
|
||||
await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined();
|
||||
delete body.name;
|
||||
await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError);
|
||||
});
|
||||
|
||||
it('rejects requests for accounts not using the IDP.', async(): Promise<void> => {
|
||||
accountStore.getSettings.mockResolvedValue({ useIdp: false });
|
||||
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
|
||||
});
|
||||
|
||||
it('creates a new credential token.', async(): Promise<void> => {
|
||||
const response = await handler.handle({ operation, body });
|
||||
expect(response.metadata.contentType).toBe(APPLICATION_JSON);
|
||||
const { id, secret } = await readJsonStream(response.data);
|
||||
expect(id).toMatch(/^token_/u);
|
||||
expect(credentialStorage.set).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret });
|
||||
expect(accountStore.getSettings).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId);
|
||||
expect(accountStore.updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.updateSettings)
|
||||
.toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]});
|
||||
});
|
||||
|
||||
it('can handle account settings with undefined client credentials.', async(): Promise<void> => {
|
||||
accountStore.getSettings.mockResolvedValue({ useIdp: true });
|
||||
const response = await handler.handle({ operation, body });
|
||||
expect(response.metadata.contentType).toBe(APPLICATION_JSON);
|
||||
const { id, secret } = await readJsonStream(response.data);
|
||||
expect(id).toMatch(/^token_/u);
|
||||
expect(credentialStorage.set).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStorage.set).toHaveBeenLastCalledWith(id, { webId: body.webId, secret });
|
||||
expect(accountStore.getSettings).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId);
|
||||
expect(accountStore.updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.updateSettings)
|
||||
.toHaveBeenLastCalledWith(body.webId, { useIdp: true, clientCredentials: [ id ]});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Operation } from '../../../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
|
||||
import type {
|
||||
ClientCredentials,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/ClientCredentialsAdapterFactory';
|
||||
import type {
|
||||
CredentialsHandlerBody,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
|
||||
import {
|
||||
DeleteCredentialsHandler,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/DeleteCredentialsHandler';
|
||||
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||
import type { KeyValueStorage } from '../../../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../../../../../../src/util/errors/BadRequestHttpError';
|
||||
import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError';
|
||||
|
||||
describe('A DeleteCredentialsHandler', (): void => {
|
||||
let operation: Operation;
|
||||
const id = 'token_id';
|
||||
let body: CredentialsHandlerBody;
|
||||
let accountStore: jest.Mocked<AccountStore>;
|
||||
let credentialStorage: jest.Mocked<KeyValueStorage<string, ClientCredentials>>;
|
||||
let handler: DeleteCredentialsHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
operation = {
|
||||
method: 'POST',
|
||||
body: new BasicRepresentation(),
|
||||
target: { path: 'http://example.com/foo' },
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
body = {
|
||||
email: 'test@example.com',
|
||||
webId: 'http://example.com/foo#me',
|
||||
delete: id,
|
||||
};
|
||||
|
||||
accountStore = {
|
||||
getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}),
|
||||
updateSettings: jest.fn(),
|
||||
} as any;
|
||||
|
||||
credentialStorage = {
|
||||
delete: jest.fn(),
|
||||
} as any;
|
||||
|
||||
handler = new DeleteCredentialsHandler(accountStore, credentialStorage);
|
||||
});
|
||||
|
||||
it('only supports bodies with a delete entry.', async(): Promise<void> => {
|
||||
await expect(handler.canHandle({ operation, body })).resolves.toBeUndefined();
|
||||
delete body.delete;
|
||||
await expect(handler.canHandle({ operation, body })).rejects.toThrow(NotImplementedHttpError);
|
||||
});
|
||||
|
||||
it('deletes the token.', async(): Promise<void> => {
|
||||
const response = await handler.handle({ operation, body });
|
||||
expect(response.metadata.contentType).toBe(APPLICATION_JSON);
|
||||
expect(credentialStorage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStorage.delete).toHaveBeenLastCalledWith(id);
|
||||
expect(accountStore.getSettings).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.getSettings).toHaveBeenLastCalledWith(body.webId);
|
||||
expect(accountStore.updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.updateSettings).toHaveBeenLastCalledWith(body.webId, { clientCredentials: []});
|
||||
});
|
||||
|
||||
it('errors if the account has no such token.', async(): Promise<void> => {
|
||||
accountStore.getSettings.mockResolvedValue({ useIdp: true, clientCredentials: []});
|
||||
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
|
||||
|
||||
accountStore.getSettings.mockResolvedValue({ useIdp: true });
|
||||
await expect(handler.handle({ operation, body })).rejects.toThrow(BadRequestHttpError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Operation } from '../../../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../../../src/http/representation/Representation';
|
||||
import type {
|
||||
CredentialsHandler,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
|
||||
import {
|
||||
EmailPasswordAuthorizer,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/EmailPasswordAuthorizer';
|
||||
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
|
||||
import { MethodNotAllowedHttpError } from '../../../../../../src/util/errors/MethodNotAllowedHttpError';
|
||||
|
||||
describe('An EmailPasswordAuthorizer', (): void => {
|
||||
const email = 'test@example.com';
|
||||
const password = 'super_secret';
|
||||
const webId = 'http://example.com/profile#me';
|
||||
let operation: Operation;
|
||||
let response: Representation;
|
||||
let accountStore: jest.Mocked<AccountStore>;
|
||||
let source: jest.Mocked<CredentialsHandler>;
|
||||
let handler: EmailPasswordAuthorizer;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
operation = {
|
||||
method: 'POST',
|
||||
body: new BasicRepresentation(JSON.stringify({ email, password }), APPLICATION_JSON),
|
||||
target: { path: 'http://example.com/foo' },
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
response = new BasicRepresentation();
|
||||
|
||||
accountStore = {
|
||||
authenticate: jest.fn().mockResolvedValue(webId),
|
||||
} as any;
|
||||
|
||||
source = {
|
||||
handleSafe: jest.fn().mockResolvedValue(response),
|
||||
} as any;
|
||||
|
||||
handler = new EmailPasswordAuthorizer(accountStore, source);
|
||||
});
|
||||
|
||||
it('requires POST methods.', async(): Promise<void> => {
|
||||
operation.method = 'GET';
|
||||
await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
|
||||
});
|
||||
|
||||
it('calls the source after validation.', async(): Promise<void> => {
|
||||
await expect(handler.handle({ operation })).resolves.toBe(response);
|
||||
expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, password);
|
||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ operation, body: { email, webId }});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Operation } from '../../../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../../../src/http/representation/BasicRepresentation';
|
||||
import type {
|
||||
CredentialsHandlerBody,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/CredentialsHandler';
|
||||
import {
|
||||
ListCredentialsHandler,
|
||||
} from '../../../../../../src/identity/interaction/email-password/credentials/ListCredentialsHandler';
|
||||
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||
import { APPLICATION_JSON } from '../../../../../../src/util/ContentTypes';
|
||||
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
|
||||
|
||||
describe('A ListCredentialsHandler', (): void => {
|
||||
let operation: Operation;
|
||||
const id = 'token_id';
|
||||
let body: CredentialsHandlerBody;
|
||||
let accountStore: jest.Mocked<AccountStore>;
|
||||
let handler: ListCredentialsHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
operation = {
|
||||
method: 'POST',
|
||||
body: new BasicRepresentation(),
|
||||
target: { path: 'http://example.com/foo' },
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
body = {
|
||||
email: 'test@example.com',
|
||||
webId: 'http://example.com/foo#me',
|
||||
delete: id,
|
||||
};
|
||||
|
||||
accountStore = {
|
||||
getSettings: jest.fn().mockResolvedValue({ clientCredentials: [ id ]}),
|
||||
updateSettings: jest.fn(),
|
||||
} as any;
|
||||
|
||||
handler = new ListCredentialsHandler(accountStore);
|
||||
});
|
||||
|
||||
it('lists all tokens.', async(): Promise<void> => {
|
||||
const response = await handler.handle({ operation, body });
|
||||
expect(response).toBeDefined();
|
||||
expect(response.metadata.contentType).toEqual(APPLICATION_JSON);
|
||||
const list = await readJsonStream(response.data);
|
||||
expect(list).toEqual([ id ]);
|
||||
});
|
||||
|
||||
it('returns an empty array if there are no tokens.', async(): Promise<void> => {
|
||||
accountStore.getSettings.mockResolvedValue({ useIdp: true });
|
||||
const response = await handler.handle({ operation, body });
|
||||
expect(response).toBeDefined();
|
||||
expect(response.metadata.contentType).toEqual(APPLICATION_JSON);
|
||||
const list = await readJsonStream(response.data);
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('A LoginHandler', (): void => {
|
||||
|
||||
it('throws an error if the account does not have the correct settings.', async(): Promise<void> => {
|
||||
input.operation = createPostJsonOperation({ email, password: 'password!' });
|
||||
accountStore.getSettings.mockResolvedValueOnce({ useIdp: false });
|
||||
accountStore.getSettings.mockResolvedValueOnce({ useIdp: false, clientCredentials: []});
|
||||
await expect(handler.handle(input))
|
||||
.rejects.toThrow('This server is not an identity provider for this account.');
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('A BaseAccountStore', (): void => {
|
||||
const email = 'test@test.com';
|
||||
const webId = 'http://test.com/#webId';
|
||||
const password = 'password!';
|
||||
const settings: AccountSettings = { useIdp: true };
|
||||
const settings: AccountSettings = { useIdp: true, clientCredentials: []};
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
const map = new Map();
|
||||
@@ -92,7 +92,7 @@ describe('A BaseAccountStore', (): void => {
|
||||
});
|
||||
|
||||
it('can update the settings.', async(): Promise<void> => {
|
||||
const newSettings = { webId, useIdp: false };
|
||||
const newSettings = { webId, useIdp: false, clientCredentials: []};
|
||||
await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined();
|
||||
await expect(store.verify(email)).resolves.toBeUndefined();
|
||||
await expect(store.updateSettings(webId, newSettings)).resolves.toBeUndefined();
|
||||
|
||||
@@ -172,7 +172,8 @@ describe('A RegistrationManager', (): void => {
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true });
|
||||
expect(accountStore.create)
|
||||
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, clientCredentials: []});
|
||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
|
||||
|
||||
@@ -200,7 +201,8 @@ describe('A RegistrationManager', (): void => {
|
||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false);
|
||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl });
|
||||
expect(accountStore.create)
|
||||
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl, clientCredentials: []});
|
||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
|
||||
@@ -222,7 +224,8 @@ describe('A RegistrationManager', (): void => {
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl });
|
||||
expect(accountStore.create)
|
||||
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []});
|
||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
||||
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||
@@ -242,7 +245,8 @@ describe('A RegistrationManager', (): void => {
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl });
|
||||
expect(accountStore.create)
|
||||
.toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl, clientCredentials: []});
|
||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
||||
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||
@@ -275,7 +279,7 @@ describe('A RegistrationManager', (): void => {
|
||||
expect(accountStore.create).toHaveBeenLastCalledWith(email,
|
||||
generatedWebID,
|
||||
password,
|
||||
{ useIdp: true, podBaseUrl });
|
||||
{ useIdp: true, podBaseUrl, clientCredentials: []});
|
||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
|
||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||
@@ -306,7 +310,7 @@ describe('A RegistrationManager', (): void => {
|
||||
expect(accountStore.create).toHaveBeenLastCalledWith(email,
|
||||
webId,
|
||||
password,
|
||||
{ useIdp: false, podBaseUrl: baseUrl });
|
||||
{ useIdp: false, podBaseUrl: baseUrl, clientCredentials: []});
|
||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(0);
|
||||
|
||||
86
test/unit/identity/storage/PassthroughAdapterFactory.test.ts
Normal file
86
test/unit/identity/storage/PassthroughAdapterFactory.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Adapter } from 'oidc-provider';
|
||||
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
|
||||
import {
|
||||
PassthroughAdapter,
|
||||
PassthroughAdapterFactory,
|
||||
} from '../../../../src/identity/storage/PassthroughAdapterFactory';
|
||||
|
||||
describe('A PassthroughAdapterFactory', (): void => {
|
||||
let sourceFactory: jest.Mocked<AdapterFactory>;
|
||||
let factory: PassthroughAdapterFactory;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
sourceFactory = {
|
||||
createStorageAdapter: jest.fn(),
|
||||
};
|
||||
|
||||
factory = new PassthroughAdapterFactory(sourceFactory);
|
||||
});
|
||||
|
||||
it('calls the source createStorageAdapter function.', async(): Promise<void> => {
|
||||
expect(factory.createStorageAdapter('Client')).toBeUndefined();
|
||||
expect(sourceFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(sourceFactory.createStorageAdapter).toHaveBeenLastCalledWith('Client');
|
||||
});
|
||||
|
||||
describe('A PassthroughAdapter', (): void => {
|
||||
let sourceAdapter: jest.Mocked<Adapter>;
|
||||
let adapter: PassthroughAdapter;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
sourceAdapter = {
|
||||
upsert: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findByUserCode: jest.fn(),
|
||||
findByUid: jest.fn(),
|
||||
consume: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
revokeByGrantId: jest.fn(),
|
||||
};
|
||||
|
||||
adapter = new PassthroughAdapter('Name', sourceAdapter);
|
||||
});
|
||||
|
||||
it('passes the call to the source for upsert.', async(): Promise<void> => {
|
||||
await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5);
|
||||
});
|
||||
|
||||
it('passes the call to the source for find.', async(): Promise<void> => {
|
||||
await expect(adapter.find('id')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.find).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.find).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
|
||||
it('passes the call to the source for findByUserCode.', async(): Promise<void> => {
|
||||
await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.findByUserCode).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.findByUserCode).toHaveBeenLastCalledWith('userCode');
|
||||
});
|
||||
|
||||
it('passes the call to the source for findByUid.', async(): Promise<void> => {
|
||||
await expect(adapter.findByUid('uid')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.findByUid).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.findByUid).toHaveBeenLastCalledWith('uid');
|
||||
});
|
||||
|
||||
it('passes the call to the source for destroy.', async(): Promise<void> => {
|
||||
await expect(adapter.destroy('id')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.destroy).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
|
||||
it('passes the call to the source for revokeByGrantId.', async(): Promise<void> => {
|
||||
await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.revokeByGrantId).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.revokeByGrantId).toHaveBeenLastCalledWith('grantId');
|
||||
});
|
||||
|
||||
it('passes the call to the source for consume.', async(): Promise<void> => {
|
||||
await expect(adapter.consume('id')).resolves.toBeUndefined();
|
||||
expect(sourceAdapter.consume).toHaveBeenCalledTimes(1);
|
||||
expect(sourceAdapter.consume).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,42 +53,6 @@ describe('A WebIdAdapterFactory', (): void => {
|
||||
adapter = factory.createStorageAdapter('Client');
|
||||
});
|
||||
|
||||
it('passes the call to the source for upsert.', async(): Promise<void> => {
|
||||
await expect(adapter.upsert('id', 'payload' as any, 5)).resolves.toBeUndefined();
|
||||
expect(source.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(source.upsert).toHaveBeenLastCalledWith('id', 'payload' as any, 5);
|
||||
});
|
||||
|
||||
it('passes the call to the source for findByUserCode.', async(): Promise<void> => {
|
||||
await expect(adapter.findByUserCode('userCode')).resolves.toBeUndefined();
|
||||
expect(source.findByUserCode).toHaveBeenCalledTimes(1);
|
||||
expect(source.findByUserCode).toHaveBeenLastCalledWith('userCode');
|
||||
});
|
||||
|
||||
it('passes the call to the source for findByUid.', async(): Promise<void> => {
|
||||
await expect(adapter.findByUid('uid')).resolves.toBeUndefined();
|
||||
expect(source.findByUid).toHaveBeenCalledTimes(1);
|
||||
expect(source.findByUid).toHaveBeenLastCalledWith('uid');
|
||||
});
|
||||
|
||||
it('passes the call to the source for destroy.', async(): Promise<void> => {
|
||||
await expect(adapter.destroy('id')).resolves.toBeUndefined();
|
||||
expect(source.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(source.destroy).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
|
||||
it('passes the call to the source for revokeByGrantId.', async(): Promise<void> => {
|
||||
await expect(adapter.revokeByGrantId('grantId')).resolves.toBeUndefined();
|
||||
expect(source.revokeByGrantId).toHaveBeenCalledTimes(1);
|
||||
expect(source.revokeByGrantId).toHaveBeenLastCalledWith('grantId');
|
||||
});
|
||||
|
||||
it('passes the call to the source for consume.', async(): Promise<void> => {
|
||||
await expect(adapter.consume('id')).resolves.toBeUndefined();
|
||||
expect(source.consume).toHaveBeenCalledTimes(1);
|
||||
expect(source.consume).toHaveBeenLastCalledWith('id');
|
||||
});
|
||||
|
||||
it('returns the source payload if there is one.', async(): Promise<void> => {
|
||||
(source.find as jest.Mock).mockResolvedValueOnce('payload!');
|
||||
await expect(adapter.find(id)).resolves.toBe('payload!');
|
||||
|
||||
Reference in New Issue
Block a user