Skip to content

Commit

Permalink
feat: Add support for client_credentials authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Apr 15, 2022
1 parent bedab90 commit 2ec8fab
Show file tree
Hide file tree
Showing 40 changed files with 1,120 additions and 125 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Expand Up @@ -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' ],
Expand Down
8 changes: 7 additions & 1 deletion RELEASE_NOTES.md
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down
20 changes: 12 additions & 8 deletions config/identity/handler/adapter-factory/webid.json
Expand Up @@ -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" }
}
}
]
}
4 changes: 3 additions & 1 deletion config/identity/handler/interaction/routes.json
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
]
}
]
Expand Down
50 changes: 50 additions & 0 deletions config/identity/handler/interaction/routes/credentials.json
@@ -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" }
}
]
}
}
},
{

}
]
}
4 changes: 4 additions & 0 deletions config/identity/handler/interaction/views/controls.json
Expand Up @@ -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" }
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions config/identity/handler/provider-factory/identity.json
Expand Up @@ -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/",
Expand All @@ -30,6 +31,7 @@
},
"features": {
"claimsParameter": { "enabled": true },
"clientCredentials": { "enabled": true },
"devInteractions": { "enabled": false },
"dPoP": { "enabled": true, "ack": "draft-03" },
"introspection": { "enabled": true },
Expand All @@ -43,6 +45,7 @@
"AccessToken": 3600,
"AuthorizationCode": 600,
"BackchannelAuthenticationRequest": 600,
"ClientCredentials": 600,
"DeviceCode": 600,
"Grant": 1209600,
"IdToken": 3600,
Expand Down
1 change: 1 addition & 0 deletions documentation/README.md
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions documentation/client-credentials.md
@@ -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.
Empty file removed documentation/idp.md
Empty file.
13 changes: 7 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions src/identity/configuration/IdentityProviderFactory.ts
Expand Up @@ -6,7 +6,6 @@ import type { JWK } from 'jose';
import { exportJWK, generateKeyPair } from 'jose';
import type { Account,
Adapter,
CanBePromise,
Configuration,
ErrorOut,
KoaContextWithOIDC,
Expand All @@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/identity/interaction/ControlHandler.ts
Expand Up @@ -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,
Expand Down

0 comments on commit 2ec8fab

Please sign in to comment.