From fdc52f50e53cd60fa220ab12d4c601237353df10 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 8 Jul 2021 12:00:46 +0200 Subject: [PATCH] feat: Simplify and merge OIDC configurations --- .componentsignore | 3 - config/identity/handler/default.json | 1 - .../identity/handler/interaction/handler.json | 6 - .../handler/provider-factory/identity.json | 49 +++- src/identity/IdentityProviderFactory.ts | 96 ------- src/identity/IdentityProviderHttpHandler.ts | 39 +-- .../configuration/ConfigurationFactory.ts | 9 - .../configuration/IdentityProviderFactory.ts | 244 ++++++++++++++++++ .../configuration/KeyConfigurationFactory.ts | 138 ---------- src/identity/configuration/ProviderFactory.ts | 13 + src/identity/interaction/InteractionPolicy.ts | 9 - .../AccountInteractionPolicy.ts | 33 --- src/index.ts | 7 +- .../identity/IdentityProviderFactory.test.ts | 113 -------- .../IdentityProviderHttpHandler.test.ts | 43 +-- .../IdentityProviderFactory.test.ts | 161 ++++++++++++ .../KeyConfigurationFactory.test.ts | 96 ------- .../AccountInteractionPolicy.test.ts | 20 -- 18 files changed, 479 insertions(+), 601 deletions(-) delete mode 100644 src/identity/IdentityProviderFactory.ts delete mode 100644 src/identity/configuration/ConfigurationFactory.ts create mode 100644 src/identity/configuration/IdentityProviderFactory.ts delete mode 100644 src/identity/configuration/KeyConfigurationFactory.ts create mode 100644 src/identity/configuration/ProviderFactory.ts delete mode 100644 src/identity/interaction/InteractionPolicy.ts delete mode 100644 src/identity/interaction/email-password/AccountInteractionPolicy.ts delete mode 100644 test/unit/identity/IdentityProviderFactory.test.ts create mode 100644 test/unit/identity/configuration/IdentityProviderFactory.test.ts delete mode 100644 test/unit/identity/configuration/KeyConfigurationFactory.test.ts delete mode 100644 test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts diff --git a/.componentsignore b/.componentsignore index 0ac96dffd..a482e25d5 100644 --- a/.componentsignore +++ b/.componentsignore @@ -1,11 +1,8 @@ [ "Adapter", "BasicRepresentation", - "Configuration", "Error", "EventEmitter", "HttpErrorOptions", - "LRUCache", - "Provider", "ValuePreferencesArg" ] diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json index 95df54bf0..3aa96994c 100644 --- a/config/identity/handler/default.json +++ b/config/identity/handler/default.json @@ -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" } diff --git a/config/identity/handler/interaction/handler.json b/config/identity/handler/interaction/handler.json index d0122ebc7..4fc29ef5e 100644 --- a/config/identity/handler/interaction/handler.json +++ b/config/identity/handler/interaction/handler.json @@ -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" } ] } diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json index 804a794a0..f28dd740a 100644 --- a/config/identity/handler/provider-factory/identity.json +++ b/config/identity/handler/provider-factory/identity.json @@ -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 + } + } } ] } diff --git a/src/identity/IdentityProviderFactory.ts b/src/identity/IdentityProviderFactory.ts deleted file mode 100644 index 57fec0d98..000000000 --- a/src/identity/IdentityProviderFactory.ts +++ /dev/null @@ -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; - }): Promise { - const configuration = await this.configurationFactory.createConfiguration(); - const augmentedConfig: Configuration = { - ...configuration, - interactions: { - policy: interactionPolicyOptions.policy, - url: interactionPolicyOptions.url, - }, - async findAccount(ctx: KoaContextWithOIDC, sub: string): Promise { - 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 { - 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 => { - // 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); - } -} diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index 4bd3405f7..58668e112 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -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 { - 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 { - 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 }); } } diff --git a/src/identity/configuration/ConfigurationFactory.ts b/src/identity/configuration/ConfigurationFactory.ts deleted file mode 100644 index daf7e277d..000000000 --- a/src/identity/configuration/ConfigurationFactory.ts +++ /dev/null @@ -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; -} diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts new file mode 100644 index 000000000..472d97702 --- /dev/null +++ b/src/identity/configuration/IdentityProviderFactory.ts @@ -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; + /** + * 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; + 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 { + 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 { + 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 { + // 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 { + // 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 => ({ + 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 => + // 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 => { + // 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 }); + }; + } +} diff --git a/src/identity/configuration/KeyConfigurationFactory.ts b/src/identity/configuration/KeyConfigurationFactory.ts deleted file mode 100644 index f7c759abd..000000000 --- a/src/identity/configuration/KeyConfigurationFactory.ts +++ /dev/null @@ -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; - - public constructor( - adapterFactory: AdapterFactory, - baseUrl: string, - idpPath: string, - storage: KeyValueStorage, - ) { - 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 { - // 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 { - // 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', - }, - }; - } -} diff --git a/src/identity/configuration/ProviderFactory.ts b/src/identity/configuration/ProviderFactory.ts new file mode 100644 index 000000000..7e68258b2 --- /dev/null +++ b/src/identity/configuration/ProviderFactory.ts @@ -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; +} diff --git a/src/identity/interaction/InteractionPolicy.ts b/src/identity/interaction/InteractionPolicy.ts deleted file mode 100644 index 38e92f90e..000000000 --- a/src/identity/interaction/InteractionPolicy.ts +++ /dev/null @@ -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; -} diff --git a/src/identity/interaction/email-password/AccountInteractionPolicy.ts b/src/identity/interaction/email-password/AccountInteractionPolicy.ts deleted file mode 100644 index d80acb419..000000000 --- a/src/identity/interaction/email-password/AccountInteractionPolicy.ts +++ /dev/null @@ -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); - } -} diff --git a/src/index.ts b/src/index.ts index 9e9a1e7fe..d41c459d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/test/unit/identity/IdentityProviderFactory.test.ts b/test/unit/identity/IdentityProviderFactory.test.ts deleted file mode 100644 index 9f1983c54..000000000 --- a/test/unit/identity/IdentityProviderFactory.test.ts +++ /dev/null @@ -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 => { - configuration = {}; - const configurationFactory: ConfigurationFactory = { - createConfiguration: async(): Promise => 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 => { - 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 => { - 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 => { - 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', - }); - }); -}); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index 368fc00a5..ee3d063eb 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -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; + let interactionHttpHandler: jest.Mocked; + let errorHandler: jest.Mocked; + let responseWriter: jest.Mocked; + let provider: jest.Mocked; let handler: IdentityProviderHttpHandler; beforeEach(async(): Promise => { @@ -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 => { 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 => { - (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 => { - 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 => { 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'); }); }); diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts new file mode 100644 index 000000000..3181c7536 --- /dev/null +++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts @@ -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; + let storage: jest.Mocked>; + let errorHandler: jest.Mocked; + let responseWriter: jest.Mocked; + let factory: IdentityProviderFactory; + + beforeEach(async(): Promise => { + 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 => { + 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 => { + // 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 => { + 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 => { + 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 => { + 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); + }); +}); diff --git a/test/unit/identity/configuration/KeyConfigurationFactory.test.ts b/test/unit/identity/configuration/KeyConfigurationFactory.test.ts deleted file mode 100644 index ec5590ca6..000000000 --- a/test/unit/identity/configuration/KeyConfigurationFactory.test.ts +++ /dev/null @@ -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; - let generator: KeyConfigurationFactory; - - beforeEach(async(): Promise => { - 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 => { - 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 => { - 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); - }); -}); diff --git a/test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts b/test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts deleted file mode 100644 index 294d74593..000000000 --- a/test/unit/identity/interaction/email-password/AccountInteractionPolicy.test.ts +++ /dev/null @@ -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 => { - expect((): any => new AccountInteractionPolicy('idp')).toThrow('idpPath needs to start with a /'); - }); - - it('has a select_account policy at index 0.', async(): Promise => { - expect(interactionPolicy.policy[0].name).toBe('select_account'); - }); - - it('sets the default url to /idp/.', async(): Promise => { - expect(interactionPolicy.url({ oidc: { uid: 'valid-uid' }} as any)).toBe('/idp/'); - }); -});