feat: Simplify and merge OIDC configurations

This commit is contained in:
Joachim Van Herwegen 2021-07-08 12:00:46 +02:00
parent d850ad1025
commit fdc52f50e5
18 changed files with 479 additions and 601 deletions

View File

@ -1,11 +1,8 @@
[
"Adapter",
"BasicRepresentation",
"Configuration",
"Error",
"EventEmitter",
"HttpErrorOptions",
"LRUCache",
"Provider",
"ValuePreferencesArg"
]

View File

@ -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" }

View File

@ -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"
}
]
}

View File

@ -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
}
}
}
]
}

View File

@ -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);
}
}

View File

@ -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 });
}
}

View File

@ -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>;
}

View 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 });
};
}
}

View File

@ -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',
},
};
}
}

View 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>;
}

View File

@ -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>;
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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',
});
});
});

View File

@ -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');
});
});

View 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);
});
});

View File

@ -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);
});
});

View File

@ -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/');
});
});