mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Simplify and merge OIDC configurations
This commit is contained in:
parent
d850ad1025
commit
fdc52f50e5
@ -1,11 +1,8 @@
|
||||
[
|
||||
"Adapter",
|
||||
"BasicRepresentation",
|
||||
"Configuration",
|
||||
"Error",
|
||||
"EventEmitter",
|
||||
"HttpErrorOptions",
|
||||
"LRUCache",
|
||||
"Provider",
|
||||
"ValuePreferencesArg"
|
||||
]
|
||||
|
@ -19,7 +19,6 @@
|
||||
"@id": "urn:solid-server:default:IdentityProviderHttpHandler",
|
||||
"@type": "IdentityProviderHttpHandler",
|
||||
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
|
||||
"interactionPolicy": { "@id": "urn:solid-server:auth:password:AccountInteractionPolicy" },
|
||||
"interactionHttpHandler": { "@id": "urn:solid-server:auth:password:InteractionHttpHandler" },
|
||||
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
||||
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }
|
||||
|
@ -40,12 +40,6 @@
|
||||
"comment": "Responsible for completing an OIDC interaction after login or registration",
|
||||
"@id": "urn:solid-server:auth:password:InteractionCompleter",
|
||||
"@type": "InteractionCompleter"
|
||||
},
|
||||
{
|
||||
"comment": "Sets up the email password interaction policy",
|
||||
"@id": "urn:solid-server:auth:password:AccountInteractionPolicy",
|
||||
"@type": "AccountInteractionPolicy",
|
||||
"idpPath": "/idp"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -5,19 +5,42 @@
|
||||
"comment": "Sets all the relevant oidc parameters.",
|
||||
"@id": "urn:solid-server:default:IdentityProviderFactory",
|
||||
"@type": "IdentityProviderFactory",
|
||||
"issuer": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"configurationFactory": { "@id": "urn:solid-server:default:IdpConfigurationFactory" },
|
||||
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
||||
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }
|
||||
},
|
||||
{
|
||||
"comment": "Sets up the JWKS and cookie keys.",
|
||||
"@id": "urn:solid-server:default:IdpConfigurationFactory",
|
||||
"@type": "KeyConfigurationFactory",
|
||||
"adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" },
|
||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"idpPath": "/idp",
|
||||
"storage": { "@id": "urn:solid-server:default:IdpStorage" }
|
||||
"args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" },
|
||||
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"args_idpPath": "/idp",
|
||||
"args_storage": { "@id": "urn:solid-server:default:IdpStorage" },
|
||||
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
||||
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
||||
"config": {
|
||||
"claims": {
|
||||
"webid": [ "webid", "client_id" ]
|
||||
},
|
||||
"cookies": {
|
||||
"long": { "signed": true, "maxAge": 86400000 },
|
||||
"short": { "signed": true }
|
||||
},
|
||||
"discovery": {
|
||||
"solid_oidc_supported": "https://solidproject.org/TR/solid-oidc"
|
||||
},
|
||||
"features": {
|
||||
"claimsParameter": { "enabled": true },
|
||||
"devInteractions": { "enabled": false },
|
||||
"dPoP": { "enabled": true, "ack": "draft-01" },
|
||||
"introspection": { "enabled": true },
|
||||
"registration": { "enabled": true },
|
||||
"revocation": { "enabled": true }
|
||||
},
|
||||
"formats": {
|
||||
"AccessToken": "jwt"
|
||||
},
|
||||
"ttl": {
|
||||
"AccessToken": 3600,
|
||||
"AuthorizationCode": 600,
|
||||
"DeviceCode": 600,
|
||||
"IdToken": 3600,
|
||||
"RefreshToken": 86400
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -1,96 +0,0 @@
|
||||
import type { AnyObject,
|
||||
CanBePromise,
|
||||
interactionPolicy as InteractionPolicy,
|
||||
KoaContextWithOIDC,
|
||||
Configuration,
|
||||
Account,
|
||||
ErrorOut } from 'oidc-provider';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
|
||||
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
|
||||
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
|
||||
import type { ConfigurationFactory } from './configuration/ConfigurationFactory';
|
||||
|
||||
/**
|
||||
* Creates a Provider from the oidc-provider library.
|
||||
* This can be used for handling many of the oidc interactions during the IDP process.
|
||||
* Full documentation can be found at https://github.com/panva/node-oidc-provider/blob/v6.x/docs/README.md
|
||||
*/
|
||||
export class IdentityProviderFactory {
|
||||
private readonly issuer: string;
|
||||
private readonly configurationFactory: ConfigurationFactory;
|
||||
private readonly errorHandler: ErrorHandler;
|
||||
private readonly responseWriter: ResponseWriter;
|
||||
|
||||
public constructor(issuer: string, configurationFactory: ConfigurationFactory,
|
||||
errorHandler: ErrorHandler, responseWriter: ResponseWriter) {
|
||||
this.issuer = issuer;
|
||||
this.configurationFactory = configurationFactory;
|
||||
this.errorHandler = errorHandler;
|
||||
this.responseWriter = responseWriter;
|
||||
}
|
||||
|
||||
public async createProvider(interactionPolicyOptions: {
|
||||
policy?: InteractionPolicy.Prompt[];
|
||||
url?: (ctx: KoaContextWithOIDC) => CanBePromise<string>;
|
||||
}): Promise<Provider> {
|
||||
const configuration = await this.configurationFactory.createConfiguration();
|
||||
const augmentedConfig: Configuration = {
|
||||
...configuration,
|
||||
interactions: {
|
||||
policy: interactionPolicyOptions.policy,
|
||||
url: interactionPolicyOptions.url,
|
||||
},
|
||||
async findAccount(ctx: KoaContextWithOIDC, sub: string): Promise<Account> {
|
||||
return {
|
||||
accountId: sub,
|
||||
async claims(): Promise<{ sub: string; [key: string]: any }> {
|
||||
return {
|
||||
sub,
|
||||
webid: sub,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
claims: {
|
||||
...configuration.claims,
|
||||
webid: [ 'webid', 'client_webid' ],
|
||||
},
|
||||
conformIdTokenClaims: false,
|
||||
features: {
|
||||
...configuration.features,
|
||||
registration: { enabled: true },
|
||||
dPoP: { enabled: true, ack: 'draft-01' },
|
||||
claimsParameter: { enabled: true },
|
||||
},
|
||||
subjectTypes: [ 'public', 'pairwise' ],
|
||||
formats: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
AccessToken: 'jwt',
|
||||
},
|
||||
audiences(): string {
|
||||
return 'solid';
|
||||
},
|
||||
extraAccessTokenClaims(ctx, token): CanBePromise<AnyObject | void | undefined> {
|
||||
if ((token as any).accountId) {
|
||||
return {
|
||||
webid: (token as any).accountId,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
client_webid: 'http://localhost:3001/',
|
||||
aud: 'solid',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
renderError:
|
||||
async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise<void> => {
|
||||
// This allows us to stream directly to to the response object, see https://github.com/koajs/koa/issues/944
|
||||
ctx.respond = false;
|
||||
const preferences: RepresentationPreferences = { type: { 'text/plain': 1 }};
|
||||
const result = await this.errorHandler.handleSafe({ error, preferences });
|
||||
await this.responseWriter.handleSafe({ response: ctx.res, result });
|
||||
},
|
||||
};
|
||||
return new Provider(this.issuer, augmentedConfig);
|
||||
}
|
||||
}
|
@ -1,19 +1,16 @@
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
|
||||
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
|
||||
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import type { HttpHandlerInput } from '../server/HttpHandler';
|
||||
import { HttpHandler } from '../server/HttpHandler';
|
||||
import { assertError, createErrorMessage } from '../util/errors/ErrorUtil';
|
||||
import type { IdentityProviderFactory } from './IdentityProviderFactory';
|
||||
import { assertError } from '../util/errors/ErrorUtil';
|
||||
import type { ProviderFactory } from './configuration/ProviderFactory';
|
||||
import type { InteractionHttpHandler } from './interaction/InteractionHttpHandler';
|
||||
import type { InteractionPolicy } from './interaction/InteractionPolicy';
|
||||
|
||||
/**
|
||||
* Handles all requests relevant for the entire IDP interaction,
|
||||
* by sending them to either the stored {@link InteractionHttpHandler},
|
||||
* or the generated {@link Provider} if the first does not support the request.
|
||||
* or the generated Provider from the {@link ProviderFactory} if the first does not support the request.
|
||||
*
|
||||
* The InteractionHttpHandler would handle all requests where we need custom behaviour,
|
||||
* such as everything related to generating and validating an account.
|
||||
@ -25,46 +22,28 @@ import type { InteractionPolicy } from './interaction/InteractionPolicy';
|
||||
export class IdentityProviderHttpHandler extends HttpHandler {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly providerFactory: IdentityProviderFactory;
|
||||
private readonly interactionPolicy: InteractionPolicy;
|
||||
private readonly providerFactory: ProviderFactory;
|
||||
private readonly interactionHttpHandler: InteractionHttpHandler;
|
||||
private readonly errorHandler: ErrorHandler;
|
||||
private readonly responseWriter: ResponseWriter;
|
||||
private provider?: Provider;
|
||||
|
||||
public constructor(
|
||||
providerFactory: IdentityProviderFactory,
|
||||
interactionPolicy: InteractionPolicy,
|
||||
providerFactory: ProviderFactory,
|
||||
interactionHttpHandler: InteractionHttpHandler,
|
||||
errorHandler: ErrorHandler,
|
||||
responseWriter: ResponseWriter,
|
||||
) {
|
||||
super();
|
||||
this.providerFactory = providerFactory;
|
||||
this.interactionPolicy = interactionPolicy;
|
||||
this.interactionHttpHandler = interactionHttpHandler;
|
||||
this.errorHandler = errorHandler;
|
||||
this.responseWriter = responseWriter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the provider or retrieve it if it has already been created.
|
||||
*/
|
||||
private async getProvider(): Promise<Provider> {
|
||||
if (!this.provider) {
|
||||
try {
|
||||
this.provider = await this.providerFactory.createProvider(this.interactionPolicy);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(`Failed to create Provider: ${createErrorMessage(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public async handle(input: HttpHandlerInput): Promise<void> {
|
||||
const provider = await this.getProvider();
|
||||
const provider = await this.providerFactory.getProvider();
|
||||
|
||||
// If our own interaction handler does not support the input, it must be a request for the OIDC library
|
||||
try {
|
||||
await this.interactionHttpHandler.canHandle({ ...input, provider });
|
||||
} catch {
|
||||
@ -76,8 +55,8 @@ export class IdentityProviderHttpHandler extends HttpHandler {
|
||||
await this.interactionHttpHandler.handle({ ...input, provider });
|
||||
} catch (error: unknown) {
|
||||
assertError(error);
|
||||
const preferences: RepresentationPreferences = { type: { 'text/plain': 1 }};
|
||||
const result = await this.errorHandler.handleSafe({ error, preferences });
|
||||
// Setting preferences to text/plain since we didn't parse accept headers, see #764
|
||||
const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}});
|
||||
await this.responseWriter.handleSafe({ response: input.response, result });
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
import type { Configuration } from 'oidc-provider';
|
||||
|
||||
/**
|
||||
* Creates an IDP Configuration to be used in
|
||||
* panva/node-oidc-provider
|
||||
*/
|
||||
export interface ConfigurationFactory {
|
||||
createConfiguration: () => Promise<Configuration>;
|
||||
}
|
244
src/identity/configuration/IdentityProviderFactory.ts
Normal file
244
src/identity/configuration/IdentityProviderFactory.ts
Normal file
@ -0,0 +1,244 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention, import/no-unresolved, tsdoc/syntax */
|
||||
// import/no-unresolved can't handle jose imports
|
||||
// tsdoc/syntax can't handle {json} parameter
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { JWK } from 'jose/jwk/from_key_like';
|
||||
import { fromKeyLike } from 'jose/jwk/from_key_like';
|
||||
import { generateKeyPair } from 'jose/util/generate_key_pair';
|
||||
import type { AnyObject,
|
||||
CanBePromise,
|
||||
KoaContextWithOIDC,
|
||||
Configuration,
|
||||
Account,
|
||||
ErrorOut, Adapter } from 'oidc-provider';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import urljoin from 'url-join';
|
||||
import type { ErrorHandler } from '../../ldp/http/ErrorHandler';
|
||||
import type { ResponseWriter } from '../../ldp/http/ResponseWriter';
|
||||
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
|
||||
import { ensureTrailingSlash } from '../../util/PathUtil';
|
||||
import type { AdapterFactory } from '../storage/AdapterFactory';
|
||||
import type { ProviderFactory } from './ProviderFactory';
|
||||
|
||||
export interface IdentityProviderFactoryArgs {
|
||||
/**
|
||||
* Factory that creates the adapter used for OIDC data storage.
|
||||
*/
|
||||
adapterFactory: AdapterFactory;
|
||||
/**
|
||||
* Base URL of the server.
|
||||
*/
|
||||
baseUrl: string;
|
||||
/**
|
||||
* Path of the IDP component in the server.
|
||||
* Should start with a slash.
|
||||
*/
|
||||
idpPath: string;
|
||||
/**
|
||||
* Storage used to store cookie and JWT keys so they can be re-used in case of multithreading.
|
||||
*/
|
||||
storage: KeyValueStorage<string, unknown>;
|
||||
/**
|
||||
* Used to convert errors thrown by the OIDC library.
|
||||
*/
|
||||
errorHandler: ErrorHandler;
|
||||
/**
|
||||
* Used to write out errors thrown by the OIDC library.
|
||||
*/
|
||||
responseWriter: ResponseWriter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OIDC Provider based on the provided configuration and parameters.
|
||||
* The provider will be cached and returned on subsequent calls.
|
||||
* Cookie and JWT keys will be stored in an internal storage so they can be re-used over multiple threads.
|
||||
* Necessary claims for Solid OIDC interactions will be added.
|
||||
* Routes will be updated based on the `baseUrl` and `idpPath`.
|
||||
*/
|
||||
export class IdentityProviderFactory implements ProviderFactory {
|
||||
private readonly config: Configuration;
|
||||
private readonly adapterFactory!: AdapterFactory;
|
||||
private readonly baseUrl!: string;
|
||||
private readonly idpPath!: string;
|
||||
private readonly storage!: KeyValueStorage<string, unknown>;
|
||||
private readonly errorHandler!: ErrorHandler;
|
||||
private readonly responseWriter!: ResponseWriter;
|
||||
|
||||
private provider?: Provider;
|
||||
|
||||
/**
|
||||
* @param config - JSON config for the OIDC library @range {json}
|
||||
* @param args - Remaining parameters required for the factory.
|
||||
*/
|
||||
public constructor(config: Configuration, args: IdentityProviderFactoryArgs) {
|
||||
if (!args.idpPath.startsWith('/')) {
|
||||
throw new Error('idpPath needs to start with a /');
|
||||
}
|
||||
this.config = config;
|
||||
Object.assign(this, args);
|
||||
}
|
||||
|
||||
public async getProvider(): Promise<Provider> {
|
||||
if (this.provider) {
|
||||
return this.provider;
|
||||
}
|
||||
this.provider = await this.createProvider();
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Provider by building a Configuration using all the stored parameters.
|
||||
*/
|
||||
private async createProvider(): Promise<Provider> {
|
||||
const config = await this.initConfig();
|
||||
|
||||
// Add correct claims to IdToken/AccessToken responses
|
||||
this.configureClaims(config);
|
||||
|
||||
// Make sure routes are contained in the IDP space
|
||||
this.configureRoutes(config);
|
||||
|
||||
// Render errors with our own error handler
|
||||
this.configureErrors(config);
|
||||
|
||||
return new Provider(this.baseUrl, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a configuration by copying the internal configuration
|
||||
* and adding the adapter, default audience and jwks/cookie keys.
|
||||
*/
|
||||
private async initConfig(): Promise<Configuration> {
|
||||
// Create a deep copy
|
||||
const config: Configuration = JSON.parse(JSON.stringify(this.config));
|
||||
|
||||
// Indicates which Adapter should be used for storing oidc data
|
||||
// The adapter function MUST be a named function.
|
||||
// See https://github.com/panva/node-oidc-provider/issues/799
|
||||
const factory = this.adapterFactory;
|
||||
config.adapter = function loadAdapter(name: string): Adapter {
|
||||
return factory.createStorageAdapter(name);
|
||||
};
|
||||
|
||||
// This needs to be a function, can't have a static string
|
||||
// Sets the "aud" value to "solid" for all tokens
|
||||
config.audiences = (): string => 'solid';
|
||||
|
||||
// Cast necessary due to typing conflict between jose 2.x and 3.x
|
||||
config.jwks = await this.generateJwks() as any;
|
||||
config.cookies = {
|
||||
...config.cookies ?? {},
|
||||
keys: await this.generateCookieKeys(),
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JWKS using a single RS256 JWK..
|
||||
* The JWKS will be cached so subsequent calls return the same key.
|
||||
*/
|
||||
private async generateJwks(): Promise<{ keys: JWK[] }> {
|
||||
// Check to see if the keys are already saved
|
||||
const key = `${this.idpPath}/jwks`;
|
||||
const jwks = await this.storage.get(key) as { keys: JWK[] } | undefined;
|
||||
if (jwks) {
|
||||
return jwks;
|
||||
}
|
||||
// If they are not, generate and save them
|
||||
const { privateKey } = await generateKeyPair('RS256');
|
||||
const jwk = await fromKeyLike(privateKey);
|
||||
// Required for Solid authn client
|
||||
jwk.alg = 'RS256';
|
||||
// In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object,
|
||||
// which is why we convert it into a plain object here.
|
||||
// Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`.
|
||||
const newJwks = { keys: [{ ...jwk }]};
|
||||
await this.storage.set(key, newJwks);
|
||||
return newJwks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cookie secret to be used for cookie signing.
|
||||
* The key will be cached so subsequent calls return the same key.
|
||||
*/
|
||||
private async generateCookieKeys(): Promise<string[]> {
|
||||
// Check to see if the keys are already saved
|
||||
const key = `${this.idpPath}/cookie-secret`;
|
||||
const cookieSecret = await this.storage.get(key);
|
||||
if (Array.isArray(cookieSecret)) {
|
||||
return cookieSecret;
|
||||
}
|
||||
// If they are not, generate and save them
|
||||
const newCookieSecret = [ randomBytes(64).toString('hex') ];
|
||||
await this.storage.set(key, newCookieSecret);
|
||||
return newCookieSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the necessary claims the to id token and access token based on the Solid OIDC spec.
|
||||
*/
|
||||
private configureClaims(config: Configuration): void {
|
||||
// Returns the id_token and adds the webid claim
|
||||
config.findAccount = async(ctx: KoaContextWithOIDC, sub: string): Promise<Account> => ({
|
||||
accountId: sub,
|
||||
claims: async(): Promise<{ sub: string; [key: string]: any }> => ({ sub, webid: sub }),
|
||||
});
|
||||
|
||||
// Add extra claims in case an AccessToken is being issued.
|
||||
// Specifically this sets the required webid and client_id claims for the access token
|
||||
config.extraAccessTokenClaims = (ctx, token): CanBePromise<AnyObject | void> =>
|
||||
// AccessToken interface is not exported
|
||||
(token as any).accountId ?
|
||||
{ webid: (token as any).accountId, client_id: 'http://localhost:3001/' } :
|
||||
{};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the route string as required by the `oidc-provider` library.
|
||||
* In case base URL is `http://test.com/foo/`, `idpPath` is `/idp` and `relative` is `device/auth`,
|
||||
* this would result in `/foo/idp/device/auth`.
|
||||
*/
|
||||
private createRoute(relative: string): string {
|
||||
return new URL(urljoin(this.baseUrl, this.idpPath, relative)).pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all the IDP routes relative to the IDP path.
|
||||
*/
|
||||
private configureRoutes(config: Configuration): void {
|
||||
// When oidc-provider cannot fulfill the authorization request for any of the possible reasons
|
||||
// (missing user session, requested ACR not fulfilled, prompt requested, ...)
|
||||
// it will resolve the interactions.url helper function and redirect the User-Agent to that url.
|
||||
config.interactions = {
|
||||
url: (): string => ensureTrailingSlash(this.idpPath),
|
||||
};
|
||||
|
||||
config.routes = {
|
||||
authorization: this.createRoute('auth'),
|
||||
check_session: this.createRoute('session/check'),
|
||||
code_verification: this.createRoute('device'),
|
||||
device_authorization: this.createRoute('device/auth'),
|
||||
end_session: this.createRoute('session/end'),
|
||||
introspection: this.createRoute('token/introspection'),
|
||||
jwks: this.createRoute('jwks'),
|
||||
pushed_authorization_request: this.createRoute('request'),
|
||||
registration: this.createRoute('reg'),
|
||||
revocation: this.createRoute('token/revocation'),
|
||||
token: this.createRoute('token'),
|
||||
userinfo: this.createRoute('me'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipes library errors to the provided ErrorHandler and ResponseWriter.
|
||||
*/
|
||||
private configureErrors(config: Configuration): void {
|
||||
config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise<void> => {
|
||||
// This allows us to stream directly to to the response object, see https://github.com/koajs/koa/issues/944
|
||||
ctx.respond = false;
|
||||
const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}});
|
||||
await this.responseWriter.handleSafe({ response: ctx.res, result });
|
||||
};
|
||||
}
|
||||
}
|
@ -1,138 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention, import/no-unresolved */
|
||||
// import/no-unresolved can't handle jose imports
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { JWK } from 'jose/jwk/from_key_like';
|
||||
import { fromKeyLike } from 'jose/jwk/from_key_like';
|
||||
import { generateKeyPair } from 'jose/util/generate_key_pair';
|
||||
import type { Adapter, Configuration } from 'oidc-provider';
|
||||
import urljoin from 'url-join';
|
||||
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
|
||||
import { ensureTrailingSlash, trimTrailingSlashes } from '../../util/PathUtil';
|
||||
import type { AdapterFactory } from '../storage/AdapterFactory';
|
||||
import type { ConfigurationFactory } from './ConfigurationFactory';
|
||||
|
||||
/**
|
||||
* An IDP Configuration Factory that generates and saves keys
|
||||
* to the provided key value store.
|
||||
*/
|
||||
export class KeyConfigurationFactory implements ConfigurationFactory {
|
||||
private readonly adapterFactory: AdapterFactory;
|
||||
private readonly baseUrl: string;
|
||||
private readonly idpPath: string;
|
||||
private readonly storage: KeyValueStorage<string, unknown>;
|
||||
|
||||
public constructor(
|
||||
adapterFactory: AdapterFactory,
|
||||
baseUrl: string,
|
||||
idpPath: string,
|
||||
storage: KeyValueStorage<string, unknown>,
|
||||
) {
|
||||
this.adapterFactory = adapterFactory;
|
||||
this.baseUrl = ensureTrailingSlash(baseUrl);
|
||||
this.idpPath = trimTrailingSlashes(idpPath);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
private get jwksKey(): string {
|
||||
return `${this.idpPath}/jwks`;
|
||||
}
|
||||
|
||||
private async generateJwks(): Promise<{ keys: JWK[] }> {
|
||||
// Check to see if the keys are already saved
|
||||
const jwks = await this.storage.get(this.jwksKey) as { keys: JWK[] } | undefined;
|
||||
if (jwks) {
|
||||
return jwks;
|
||||
}
|
||||
// If they are not, generate and save them
|
||||
const { privateKey } = await generateKeyPair('RS256');
|
||||
const jwk = await fromKeyLike(privateKey);
|
||||
// Required for Solid authn client
|
||||
jwk.alg = 'RS256';
|
||||
// In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object,
|
||||
// which is why we convert it into a plain object here.
|
||||
// Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`.
|
||||
const newJwks = { keys: [{ ...jwk }]};
|
||||
await this.storage.set(this.jwksKey, newJwks);
|
||||
return newJwks;
|
||||
}
|
||||
|
||||
private get cookieSecretKey(): string {
|
||||
return `${this.idpPath}/cookie-secret`;
|
||||
}
|
||||
|
||||
private async generateCookieKeys(): Promise<string[]> {
|
||||
// Check to see if the keys are already saved
|
||||
const cookieSecret = await this.storage.get(this.cookieSecretKey);
|
||||
if (Array.isArray(cookieSecret)) {
|
||||
return cookieSecret;
|
||||
}
|
||||
// If they are not, generate and save them
|
||||
const newCookieSecret = [ randomBytes(64).toString('hex') ];
|
||||
await this.storage.set(this.cookieSecretKey, newCookieSecret);
|
||||
return newCookieSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the route string as required by the `oidc-provider` library.
|
||||
* In case base URL is `http://test.com/foo/`, `idpPath` is `/idp` and `relative` is `device/auth`,
|
||||
* this would result in `/foo/idp/device/auth`.
|
||||
*/
|
||||
private createRoute(relative: string): string {
|
||||
return new URL(urljoin(this.baseUrl, this.idpPath, relative)).pathname;
|
||||
}
|
||||
|
||||
public async createConfiguration(): Promise<Configuration> {
|
||||
// Cast necessary due to typing conflict between jose 2.x and 3.x
|
||||
const jwks = await this.generateJwks() as any;
|
||||
const cookieKeys = await this.generateCookieKeys();
|
||||
|
||||
// The adapter function MUST be a named function.
|
||||
// See https://github.com/panva/node-oidc-provider/issues/799
|
||||
const factory = this.adapterFactory;
|
||||
return {
|
||||
adapter: function loadAdapter(name: string): Adapter {
|
||||
return factory.createStorageAdapter(name);
|
||||
},
|
||||
cookies: {
|
||||
long: { signed: true, maxAge: 1 * 24 * 60 * 60 * 1000 },
|
||||
short: { signed: true },
|
||||
keys: cookieKeys,
|
||||
},
|
||||
conformIdTokenClaims: false,
|
||||
features: {
|
||||
devInteractions: { enabled: false },
|
||||
deviceFlow: { enabled: true },
|
||||
introspection: { enabled: true },
|
||||
revocation: { enabled: true },
|
||||
registration: { enabled: true },
|
||||
claimsParameter: { enabled: true },
|
||||
},
|
||||
jwks,
|
||||
ttl: {
|
||||
AccessToken: 1 * 60 * 60,
|
||||
AuthorizationCode: 10 * 60,
|
||||
IdToken: 1 * 60 * 60,
|
||||
DeviceCode: 10 * 60,
|
||||
RefreshToken: 1 * 24 * 60 * 60,
|
||||
},
|
||||
subjectTypes: [ 'public', 'pairwise' ],
|
||||
routes: {
|
||||
authorization: this.createRoute('auth'),
|
||||
check_session: this.createRoute('session/check'),
|
||||
code_verification: this.createRoute('device'),
|
||||
device_authorization: this.createRoute('device/auth'),
|
||||
end_session: this.createRoute('session/end'),
|
||||
introspection: this.createRoute('token/introspection'),
|
||||
jwks: this.createRoute('jwks'),
|
||||
pushed_authorization_request: this.createRoute('request'),
|
||||
registration: this.createRoute('reg'),
|
||||
revocation: this.createRoute('token/revocation'),
|
||||
token: this.createRoute('token'),
|
||||
userinfo: this.createRoute('me'),
|
||||
},
|
||||
discovery: {
|
||||
solid_oidc_supported: 'https://solidproject.org/TR/solid-oidc',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
13
src/identity/configuration/ProviderFactory.ts
Normal file
13
src/identity/configuration/ProviderFactory.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
/**
|
||||
* Returns a Provider of OIDC interactions.
|
||||
*/
|
||||
export interface ProviderFactory {
|
||||
/**
|
||||
* Gets a provider from the factory.
|
||||
* Multiple calls to this function should return providers that produce the same results.
|
||||
* This is mostly relevant for signing keys.
|
||||
*/
|
||||
getProvider: () => Promise<Provider>;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import type { CanBePromise, interactionPolicy, KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
/**
|
||||
* Config options to communicate exactly how to handle requests.
|
||||
*/
|
||||
export interface InteractionPolicy {
|
||||
policy: interactionPolicy.Prompt[];
|
||||
url: (ctx: KoaContextWithOIDC) => CanBePromise<string>;
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import type { KoaContextWithOIDC } from 'oidc-provider';
|
||||
import { interactionPolicy } from 'oidc-provider';
|
||||
import { ensureTrailingSlash } from '../../../util/PathUtil';
|
||||
import type {
|
||||
InteractionPolicy,
|
||||
} from '../InteractionPolicy';
|
||||
|
||||
/**
|
||||
* Interaction policy that redirects to `idpPath`.
|
||||
* Uses the `select_account` interaction policy.
|
||||
*/
|
||||
export class AccountInteractionPolicy implements InteractionPolicy {
|
||||
public readonly policy: interactionPolicy.Prompt[];
|
||||
public readonly url: (ctx: KoaContextWithOIDC) => string;
|
||||
|
||||
public constructor(idpPath: string) {
|
||||
if (!idpPath.startsWith('/')) {
|
||||
throw new Error('idpPath needs to start with a /');
|
||||
}
|
||||
const interactions = interactionPolicy.base();
|
||||
const selectAccount = new interactionPolicy.Prompt({
|
||||
name: 'select_account',
|
||||
requestable: true,
|
||||
});
|
||||
interactions.add(selectAccount, 0);
|
||||
this.policy = interactions;
|
||||
|
||||
// When oidc-provider cannot fulfill the authorization request for any of the possible reasons
|
||||
// (missing user session, requested ACR not fulfilled, prompt requested, ...)
|
||||
// it will resolve the interactions.url helper function and redirect the User-Agent to that url.
|
||||
this.url = (): string => ensureTrailingSlash(idpPath);
|
||||
}
|
||||
}
|
@ -16,8 +16,8 @@ export * from './authorization/WebAclAuthorization';
|
||||
export * from './authorization/WebAclAuthorizer';
|
||||
|
||||
// Identity/Configuration
|
||||
export * from './identity/configuration/ConfigurationFactory';
|
||||
export * from './identity/configuration/KeyConfigurationFactory';
|
||||
export * from './identity/configuration/IdentityProviderFactory';
|
||||
export * from './identity/configuration/ProviderFactory';
|
||||
|
||||
// Identity/Interaction/Email-Password/Handler
|
||||
export * from './identity/interaction/email-password/handler/ForgotPasswordHandler';
|
||||
@ -32,7 +32,6 @@ export * from './identity/interaction/email-password/storage/AccountStore';
|
||||
export * from './identity/interaction/email-password/storage/BaseAccountStore';
|
||||
|
||||
// Identity/Interaction/Email-Password
|
||||
export * from './identity/interaction/email-password/AccountInteractionPolicy';
|
||||
export * from './identity/interaction/email-password/EmailPasswordUtil';
|
||||
|
||||
// Identity/Interaction/Util
|
||||
@ -49,7 +48,6 @@ export * from './identity/interaction/util/TemplateRenderer';
|
||||
|
||||
// Identity/Interaction
|
||||
export * from './identity/interaction/InteractionHttpHandler';
|
||||
export * from './identity/interaction/InteractionPolicy';
|
||||
export * from './identity/interaction/SessionHttpHandler';
|
||||
|
||||
// Identity/Ownership
|
||||
@ -63,7 +61,6 @@ export * from './identity/storage/ExpiringAdapterFactory';
|
||||
export * from './identity/storage/WrappedFetchAdapterFactory';
|
||||
|
||||
// Identity
|
||||
export * from './identity/IdentityProviderFactory';
|
||||
export * from './identity/IdentityProviderHttpHandler';
|
||||
|
||||
// Init/Final
|
||||
|
@ -1,113 +0,0 @@
|
||||
import type { interactionPolicy, Configuration, KoaContextWithOIDC } from 'oidc-provider';
|
||||
import type { ConfigurationFactory } from '../../../src/identity/configuration/ConfigurationFactory';
|
||||
import { IdentityProviderFactory } from '../../../src/identity/IdentityProviderFactory';
|
||||
import type { InteractionPolicy } from '../../../src/identity/interaction/InteractionPolicy';
|
||||
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
|
||||
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
|
||||
import type { HttpResponse } from '../../../src/server/HttpResponse';
|
||||
|
||||
jest.mock('oidc-provider', (): any => ({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Provider: jest.fn().mockImplementation((issuer: string, config: Configuration): any => ({ issuer, config })),
|
||||
}));
|
||||
|
||||
describe('An IdentityProviderFactory', (): void => {
|
||||
const issuer = 'http://test.com/';
|
||||
const idpPolicy: InteractionPolicy = {
|
||||
policy: [ 'prompt' as unknown as interactionPolicy.Prompt ],
|
||||
url: (ctx: KoaContextWithOIDC): string => `/idp/interaction/${ctx.oidc.uid}`,
|
||||
};
|
||||
const webId = 'http://alice.test.com/card#me';
|
||||
let configuration: any;
|
||||
let errorHandler: ErrorHandler;
|
||||
let responseWriter: ResponseWriter;
|
||||
let factory: IdentityProviderFactory;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
configuration = {};
|
||||
const configurationFactory: ConfigurationFactory = {
|
||||
createConfiguration: async(): Promise<any> => configuration,
|
||||
};
|
||||
|
||||
errorHandler = {
|
||||
handleSafe: jest.fn().mockResolvedValue({ statusCode: 500 }),
|
||||
} as any;
|
||||
|
||||
responseWriter = { handleSafe: jest.fn() } as any;
|
||||
|
||||
factory = new IdentityProviderFactory(issuer, configurationFactory, errorHandler, responseWriter);
|
||||
});
|
||||
|
||||
it('has fixed default values.', async(): Promise<void> => {
|
||||
const result = await factory.createProvider(idpPolicy) as any;
|
||||
expect(result.issuer).toBe(issuer);
|
||||
expect(result.config.interactions).toEqual(idpPolicy);
|
||||
|
||||
const findResult = await result.config.findAccount({}, webId);
|
||||
expect(findResult.accountId).toBe(webId);
|
||||
await expect(findResult.claims()).resolves.toEqual({ sub: webId, webid: webId });
|
||||
|
||||
expect(result.config.claims).toEqual({ webid: [ 'webid', 'client_webid' ]});
|
||||
expect(result.config.conformIdTokenClaims).toBe(false);
|
||||
expect(result.config.features).toEqual({
|
||||
registration: { enabled: true },
|
||||
dPoP: { enabled: true, ack: 'draft-01' },
|
||||
claimsParameter: { enabled: true },
|
||||
});
|
||||
expect(result.config.subjectTypes).toEqual([ 'public', 'pairwise' ]);
|
||||
expect(result.config.formats).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
AccessToken: 'jwt',
|
||||
});
|
||||
expect(result.config.audiences()).toBe('solid');
|
||||
|
||||
expect(result.config.extraAccessTokenClaims({}, {})).toEqual({});
|
||||
expect(result.config.extraAccessTokenClaims({}, { accountId: webId })).toEqual({
|
||||
webid: webId,
|
||||
// This will need to change once #718 is fixed
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
client_webid: 'http://localhost:3001/',
|
||||
aud: 'solid',
|
||||
});
|
||||
|
||||
// Test the renderError function
|
||||
const response: HttpResponse = { } as any;
|
||||
await expect(result.config.renderError({ res: response }, null, 'error!')).resolves.toBeUndefined();
|
||||
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(errorHandler.handleSafe)
|
||||
.toHaveBeenLastCalledWith({ error: 'error!', preferences: { type: { 'text/plain': 1 }}});
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
|
||||
});
|
||||
|
||||
it('overwrites fields from the factory config.', async(): Promise<void> => {
|
||||
configuration.dummy = 'value!';
|
||||
configuration.conformIdTokenClaims = true;
|
||||
const result = await factory.createProvider(idpPolicy) as any;
|
||||
expect(result.config.dummy).toBe('value!');
|
||||
expect(result.config.conformIdTokenClaims).toBe(false);
|
||||
});
|
||||
|
||||
it('copies specific object values from the factory config.', async(): Promise<void> => {
|
||||
configuration.interactions = { dummy: 'interaction!' };
|
||||
configuration.claims = { dummy: 'claim!' };
|
||||
configuration.features = { dummy: 'feature!' };
|
||||
configuration.subjectTypes = [ 'dummy!' ];
|
||||
configuration.formats = { dummy: 'format!' };
|
||||
|
||||
const result = await factory.createProvider({ policy: 'policy!', url: 'url!' } as any) as any;
|
||||
expect(result.config.interactions).toEqual({ policy: 'policy!', url: 'url!' });
|
||||
expect(result.config.claims).toEqual({ dummy: 'claim!', webid: [ 'webid', 'client_webid' ]});
|
||||
expect(result.config.features).toEqual({
|
||||
dummy: 'feature!',
|
||||
registration: { enabled: true },
|
||||
dPoP: { enabled: true, ack: 'draft-01' },
|
||||
claimsParameter: { enabled: true },
|
||||
});
|
||||
expect(result.config.subjectTypes).toEqual([ 'public', 'pairwise' ]);
|
||||
expect(result.config.formats).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
AccessToken: 'jwt',
|
||||
});
|
||||
});
|
||||
});
|
@ -1,8 +1,7 @@
|
||||
import type { interactionPolicy, KoaContextWithOIDC, Provider } from 'oidc-provider';
|
||||
import type { IdentityProviderFactory } from '../../../src/identity/IdentityProviderFactory';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
|
||||
import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
|
||||
import type { InteractionHttpHandler } from '../../../src/identity/interaction/InteractionHttpHandler';
|
||||
import type { InteractionPolicy } from '../../../src/identity/interaction/InteractionPolicy';
|
||||
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
|
||||
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
|
||||
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
|
||||
@ -12,15 +11,11 @@ import type { HttpResponse } from '../../../src/server/HttpResponse';
|
||||
describe('An IdentityProviderHttpHandler', (): void => {
|
||||
const request: HttpRequest = {} as any;
|
||||
const response: HttpResponse = {} as any;
|
||||
let providerFactory: IdentityProviderFactory;
|
||||
const idpPolicy: InteractionPolicy = {
|
||||
policy: [ 'prompt' as unknown as interactionPolicy.Prompt ],
|
||||
url: (ctx: KoaContextWithOIDC): string => `/idp/interaction/${ctx.oidc.uid}`,
|
||||
};
|
||||
let interactionHttpHandler: InteractionHttpHandler;
|
||||
let errorHandler: ErrorHandler;
|
||||
let responseWriter: ResponseWriter;
|
||||
let provider: Provider;
|
||||
let providerFactory: jest.Mocked<ProviderFactory>;
|
||||
let interactionHttpHandler: jest.Mocked<InteractionHttpHandler>;
|
||||
let errorHandler: jest.Mocked<ErrorHandler>;
|
||||
let responseWriter: jest.Mocked<ResponseWriter>;
|
||||
let provider: jest.Mocked<Provider>;
|
||||
let handler: IdentityProviderHttpHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
@ -29,8 +24,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
} as any;
|
||||
|
||||
providerFactory = {
|
||||
createProvider: jest.fn().mockResolvedValue(provider),
|
||||
} as any;
|
||||
getProvider: jest.fn().mockResolvedValue(provider),
|
||||
};
|
||||
|
||||
interactionHttpHandler = {
|
||||
canHandle: jest.fn(),
|
||||
@ -43,7 +38,6 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
|
||||
handler = new IdentityProviderHttpHandler(
|
||||
providerFactory,
|
||||
idpPolicy,
|
||||
interactionHttpHandler,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
@ -70,8 +64,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
it('returns an error response if there was an issue with the interaction handler.', async(): Promise<void> => {
|
||||
const error = new Error('error!');
|
||||
const errorResponse: ResponseDescription = { statusCode: 500 };
|
||||
(interactionHttpHandler.handle as jest.Mock).mockRejectedValueOnce(error);
|
||||
(errorHandler.handleSafe as jest.Mock).mockResolvedValueOnce(errorResponse);
|
||||
interactionHttpHandler.handle.mockRejectedValueOnce(error);
|
||||
errorHandler.handleSafe.mockResolvedValueOnce(errorResponse);
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
expect(provider.callback).toHaveBeenCalledTimes(0);
|
||||
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1);
|
||||
@ -83,25 +77,16 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
});
|
||||
|
||||
it('re-throws the error if it is not a native Error.', async(): Promise<void> => {
|
||||
(interactionHttpHandler.handle as jest.Mock).mockRejectedValueOnce('apple!');
|
||||
interactionHttpHandler.handle.mockRejectedValueOnce('apple!');
|
||||
await expect(handler.handle({ request, response })).rejects.toEqual('apple!');
|
||||
});
|
||||
|
||||
it('caches the provider after creating it.', async(): Promise<void> => {
|
||||
expect(providerFactory.createProvider).toHaveBeenCalledTimes(0);
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
expect(providerFactory.createProvider).toHaveBeenCalledTimes(1);
|
||||
expect(providerFactory.createProvider).toHaveBeenLastCalledWith(idpPolicy);
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
expect(providerFactory.createProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('errors if there is an issue creating the provider.', async(): Promise<void> => {
|
||||
const error = new Error('error!');
|
||||
(providerFactory.createProvider as jest.Mock).mockRejectedValueOnce(error);
|
||||
providerFactory.getProvider.mockRejectedValueOnce(error);
|
||||
await expect(handler.handle({ request, response })).rejects.toThrow(error);
|
||||
|
||||
(providerFactory.createProvider as jest.Mock).mockRejectedValueOnce('apple');
|
||||
providerFactory.getProvider.mockRejectedValueOnce('apple');
|
||||
await expect(handler.handle({ request, response })).rejects.toBe('apple');
|
||||
});
|
||||
});
|
||||
|
161
test/unit/identity/configuration/IdentityProviderFactory.test.ts
Normal file
161
test/unit/identity/configuration/IdentityProviderFactory.test.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import type { Configuration } from 'oidc-provider';
|
||||
import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory';
|
||||
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
|
||||
import type { ErrorHandler } from '../../../../src/ldp/http/ErrorHandler';
|
||||
import type { ResponseWriter } from '../../../../src/ldp/http/ResponseWriter';
|
||||
import type { HttpResponse } from '../../../../src/server/HttpResponse';
|
||||
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
jest.mock('oidc-provider', (): any => ({
|
||||
Provider: jest.fn().mockImplementation((issuer: string, config: Configuration): any => ({ issuer, config })),
|
||||
}));
|
||||
|
||||
const routes = {
|
||||
authorization: '/foo/idp/auth',
|
||||
check_session: '/foo/idp/session/check',
|
||||
code_verification: '/foo/idp/device',
|
||||
device_authorization: '/foo/idp/device/auth',
|
||||
end_session: '/foo/idp/session/end',
|
||||
introspection: '/foo/idp/token/introspection',
|
||||
jwks: '/foo/idp/jwks',
|
||||
pushed_authorization_request: '/foo/idp/request',
|
||||
registration: '/foo/idp/reg',
|
||||
revocation: '/foo/idp/token/revocation',
|
||||
token: '/foo/idp/token',
|
||||
userinfo: '/foo/idp/me',
|
||||
};
|
||||
|
||||
describe('An IdentityProviderFactory', (): void => {
|
||||
let baseConfig: Configuration;
|
||||
const baseUrl = 'http://test.com/foo/';
|
||||
const idpPath = '/idp';
|
||||
const webId = 'http://alice.test.com/card#me';
|
||||
let adapterFactory: jest.Mocked<AdapterFactory>;
|
||||
let storage: jest.Mocked<KeyValueStorage<string, any>>;
|
||||
let errorHandler: jest.Mocked<ErrorHandler>;
|
||||
let responseWriter: jest.Mocked<ResponseWriter>;
|
||||
let factory: IdentityProviderFactory;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}};
|
||||
|
||||
adapterFactory = {
|
||||
createStorageAdapter: jest.fn().mockReturnValue('adapter!'),
|
||||
};
|
||||
|
||||
const map = new Map();
|
||||
storage = {
|
||||
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;
|
||||
|
||||
responseWriter = { handleSafe: jest.fn() } as any;
|
||||
|
||||
factory = new IdentityProviderFactory(baseConfig, {
|
||||
adapterFactory,
|
||||
baseUrl,
|
||||
idpPath,
|
||||
storage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
});
|
||||
});
|
||||
|
||||
it('errors if the idpPath parameter does not start with a slash.', async(): Promise<void> => {
|
||||
expect((): any => new IdentityProviderFactory(baseConfig, {
|
||||
adapterFactory,
|
||||
baseUrl,
|
||||
idpPath: 'idp',
|
||||
storage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
})).toThrow('idpPath needs to start with a /');
|
||||
});
|
||||
|
||||
it('creates a correct configuration.', async(): Promise<void> => {
|
||||
// This is the output of our mock function
|
||||
const { issuer, config } = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
|
||||
expect(issuer).toBe(baseUrl);
|
||||
|
||||
// Copies the base config
|
||||
expect(config.claims).toEqual(baseConfig.claims);
|
||||
|
||||
(config.adapter as (name: string) => any)('test!');
|
||||
expect(adapterFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!');
|
||||
|
||||
expect(config.cookies?.keys).toEqual([ expect.any(String) ]);
|
||||
expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]});
|
||||
expect(config.routes).toEqual(routes);
|
||||
|
||||
expect((config.interactions?.url as any)()).toEqual('/idp/');
|
||||
expect((config.audiences as any)()).toBe('solid');
|
||||
|
||||
const findResult = await config.findAccount?.({} as any, webId);
|
||||
expect(findResult?.accountId).toBe(webId);
|
||||
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId });
|
||||
|
||||
expect((config.extraAccessTokenClaims as any)({}, {})).toEqual({});
|
||||
expect((config.extraAccessTokenClaims as any)({}, { accountId: webId })).toEqual({
|
||||
webid: webId,
|
||||
// This will need to change once #718 is fixed
|
||||
client_id: 'http://localhost:3001/',
|
||||
});
|
||||
|
||||
// Test the renderError function
|
||||
const response = { } as HttpResponse;
|
||||
await expect((config.renderError as any)({ res: response }, null, 'error!')).resolves.toBeUndefined();
|
||||
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(errorHandler.handleSafe)
|
||||
.toHaveBeenLastCalledWith({ error: 'error!', preferences: { type: { 'text/plain': 1 }}});
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
|
||||
});
|
||||
|
||||
it('copies a field from the input config if values need to be added to it.', async(): Promise<void> => {
|
||||
baseConfig.cookies = {
|
||||
long: { signed: true },
|
||||
};
|
||||
factory = new IdentityProviderFactory(baseConfig, {
|
||||
adapterFactory,
|
||||
baseUrl,
|
||||
idpPath,
|
||||
storage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
});
|
||||
const { config } = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
|
||||
expect(config.cookies?.long?.signed).toBe(true);
|
||||
});
|
||||
|
||||
it('caches the provider.', async(): Promise<void> => {
|
||||
const result1 = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
|
||||
const result2 = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
|
||||
it('uses cached keys in case they already exist.', async(): Promise<void> => {
|
||||
const result1 = await factory.getProvider() as unknown as { issuer: string; config: Configuration };
|
||||
// Create a new factory that is not cached yet
|
||||
const factory2 = new IdentityProviderFactory(baseConfig, {
|
||||
adapterFactory,
|
||||
baseUrl,
|
||||
idpPath,
|
||||
storage,
|
||||
errorHandler,
|
||||
responseWriter,
|
||||
});
|
||||
const result2 = await factory2.getProvider() as unknown as { issuer: string; config: Configuration };
|
||||
expect(result1.config.cookies).toEqual(result2.config.cookies);
|
||||
expect(result1.config.jwks).toEqual(result2.config.jwks);
|
||||
expect(storage.get).toHaveBeenCalledTimes(4);
|
||||
expect(storage.set).toHaveBeenCalledTimes(2);
|
||||
expect(storage.set).toHaveBeenCalledWith('/idp/jwks', result1.config.jwks);
|
||||
expect(storage.set).toHaveBeenCalledWith('/idp/cookie-secret', result1.config.cookies?.keys);
|
||||
});
|
||||
});
|
@ -1,96 +0,0 @@
|
||||
import { KeyConfigurationFactory } from '../../../../src/identity/configuration/KeyConfigurationFactory';
|
||||
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
|
||||
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
function getExpected(adapter: any, cookieKeys: any, jwks: any): any {
|
||||
return {
|
||||
adapter,
|
||||
cookies: {
|
||||
long: { signed: true, maxAge: 1 * 24 * 60 * 60 * 1000 },
|
||||
short: { signed: true },
|
||||
keys: cookieKeys,
|
||||
},
|
||||
conformIdTokenClaims: false,
|
||||
features: {
|
||||
devInteractions: { enabled: false },
|
||||
deviceFlow: { enabled: true },
|
||||
introspection: { enabled: true },
|
||||
revocation: { enabled: true },
|
||||
registration: { enabled: true },
|
||||
claimsParameter: { enabled: true },
|
||||
},
|
||||
jwks,
|
||||
ttl: {
|
||||
AccessToken: 1 * 60 * 60,
|
||||
AuthorizationCode: 10 * 60,
|
||||
IdToken: 1 * 60 * 60,
|
||||
DeviceCode: 10 * 60,
|
||||
RefreshToken: 1 * 24 * 60 * 60,
|
||||
},
|
||||
subjectTypes: [ 'public', 'pairwise' ],
|
||||
routes: {
|
||||
authorization: '/foo/idp/auth',
|
||||
check_session: '/foo/idp/session/check',
|
||||
code_verification: '/foo/idp/device',
|
||||
device_authorization: '/foo/idp/device/auth',
|
||||
end_session: '/foo/idp/session/end',
|
||||
introspection: '/foo/idp/token/introspection',
|
||||
jwks: '/foo/idp/jwks',
|
||||
pushed_authorization_request: '/foo/idp/request',
|
||||
registration: '/foo/idp/reg',
|
||||
revocation: '/foo/idp/token/revocation',
|
||||
token: '/foo/idp/token',
|
||||
userinfo: '/foo/idp/me',
|
||||
},
|
||||
discovery: {
|
||||
solid_oidc_supported: 'https://solidproject.org/TR/solid-oidc',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('A KeyConfigurationFactory', (): void => {
|
||||
let storageAdapterFactory: AdapterFactory;
|
||||
const baseUrl = 'http://test.com/foo/';
|
||||
const idpPathName = 'idp';
|
||||
let storage: KeyValueStorage<string, any>;
|
||||
let generator: KeyConfigurationFactory;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
storageAdapterFactory = {
|
||||
createStorageAdapter: jest.fn().mockReturnValue('adapter!'),
|
||||
};
|
||||
|
||||
const map = new Map();
|
||||
storage = {
|
||||
get: jest.fn((id: string): any => map.get(id)),
|
||||
set: jest.fn((id: string, value: any): any => map.set(id, value)),
|
||||
} as any;
|
||||
|
||||
generator = new KeyConfigurationFactory(storageAdapterFactory, baseUrl, idpPathName, storage);
|
||||
});
|
||||
|
||||
it('creates a correct configuration.', async(): Promise<void> => {
|
||||
const result = await generator.createConfiguration();
|
||||
expect(result).toEqual(getExpected(
|
||||
expect.any(Function),
|
||||
[ expect.any(String) ],
|
||||
{ keys: [ expect.objectContaining({ kty: 'RSA' }) ]},
|
||||
));
|
||||
|
||||
(result.adapter as (name: string) => any)('test!');
|
||||
expect(storageAdapterFactory.createStorageAdapter).toHaveBeenCalledTimes(1);
|
||||
expect(storageAdapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!');
|
||||
});
|
||||
|
||||
it('stores cookie keys and jwks for re-use.', async(): Promise<void> => {
|
||||
const result = await generator.createConfiguration();
|
||||
const result2 = await generator.createConfiguration();
|
||||
expect(result.cookies).toEqual(result2.cookies);
|
||||
expect(result.jwks).toEqual(result2.jwks);
|
||||
expect(storage.get).toHaveBeenCalledTimes(4);
|
||||
expect(storage.set).toHaveBeenCalledTimes(2);
|
||||
expect(storage.set).toHaveBeenCalledWith('idp/jwks', result.jwks);
|
||||
expect(storage.set).toHaveBeenCalledWith('idp/cookie-secret', result.cookies?.keys);
|
||||
});
|
||||
});
|
@ -1,20 +0,0 @@
|
||||
import {
|
||||
AccountInteractionPolicy,
|
||||
} from '../../../../../src/identity/interaction/email-password/AccountInteractionPolicy';
|
||||
|
||||
describe('An AccountInteractionPolicy', (): void => {
|
||||
const idpPath = '/idp';
|
||||
const interactionPolicy = new AccountInteractionPolicy(idpPath);
|
||||
|
||||
it('errors if the idpPath parameter does not start with a slash.', async(): Promise<void> => {
|
||||
expect((): any => new AccountInteractionPolicy('idp')).toThrow('idpPath needs to start with a /');
|
||||
});
|
||||
|
||||
it('has a select_account policy at index 0.', async(): Promise<void> => {
|
||||
expect(interactionPolicy.policy[0].name).toBe('select_account');
|
||||
});
|
||||
|
||||
it('sets the default url to /idp/.', async(): Promise<void> => {
|
||||
expect(interactionPolicy.url({ oidc: { uid: 'valid-uid' }} as any)).toBe('/idp/');
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user