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