UNPKG

@solid/oidc-auth-manager

Version:

An OpenID Connect (OIDC) authentication manager (OP, RP and RS) for decentralized peer-to-peer authentication

507 lines (432 loc) 14.5 kB
'use strict' const fs = require('fs-extra') const path = require('path') const { URL } = require('whatwg-url') const validUrl = require('valid-url') const ResourceAuthenticator = require('@solid/oidc-rs') const KVPFileStore = require('kvplus-files') const MultiRpClient = require('@solid/solid-multi-rp-client') const OIDCProvider = require('@solid/oidc-op') const UserStore = require('./user-store') const HostAPI = require('./host-api') const { discoverProviderFor } = require('./preferred-provider') const DEFAULT_DB_PATH = './db/oidc' class OidcManager { /** * @constructor * @param options {Object} Options hashmap object * * @param [options.storePaths] {Object} * @param [options.storePaths.multiRpStore] {string} * @param [options.storePaths.providerStore] {string} * @param [options.storePaths.userStore] {string} * * Config for OIDCProvider: * @param [options.serverUri] {string} URI of this peer node, will be used * as both the Provider URI (`iss`) and the ResourceServer URI. * * @param [options.host] {Object} Injected host behavior object * @param [options.host.authenticate] {Function} * @param [options.host.obtainConsent] {Function} * @param [options.host.logout] {Function} * * Config for MultiRpClient: * @param [options.authCallbackUri] {string} e.g. '/api/oidc/rp' * @param [options.postLogoutUri] {string} e.g. '/goodbye' * * Config for UserStore: * @param [options.saltRounds] {number} Number of bcrypt password salt rounds * * @param [options.debug] {Function} Debug function (defaults to console.log) * * @param [options.delayBeforeRegisteringInitialClient] {number} Number of * milliseconds to delay before initializing a local RP client. */ constructor (options) { this.storePaths = options.storePaths this.providerUri = options.serverUri this.serverUri = options.serverUri this.host = options.host this.authCallbackUri = options.authCallbackUri this.postLogoutUri = options.postLogoutUri this.saltRounds = options.saltRounds this.rs = null this.clients = null this.localRp = null this.provider = null this.users = null this.debug = options.debug || console.log.bind(console) this.delayBeforeRegisteringInitialClient = options.delayBeforeRegisteringInitialClient } /** * Factory method, initializes and returns an instance of OidcManager. * * @param config {Object} Options hashmap object * * @param [options.debug] {Function} Debug function (defaults to console.log) * * @param [config.dbPath='./db/oidc'] {string} Folder in which to store the * auth-related collection stores (users, clients, tokens). * * Config for OIDCProvider: * @param config.serverUri {string} URI of the OpenID Connect Provider * @param [config.host] {Object} Injected host behavior object, * see `providerFrom()` docstring. * * Config for MultiRpClient: * @param config.authCallbackUri {string} * @param config.postLogoutUri {string} * * Config for UserStore: * @param [config.saltRounds] {number} Number of bcrypt password salt rounds * * @param [config.delayBeforeRegisteringInitialClient] {number} Number of * milliseconds to delay before initializing a local RP client. * * @return {OidcManager} */ static from (config) { const options = { debug: config.debug, providerUri: config.serverUri || config.providerUri, serverUri: config.serverUri || config.providerUri, host: config.host, authCallbackUri: config.authCallbackUri, postLogoutUri: config.postLogoutUri, saltRounds: config.saltRounds, delayBeforeRegisteringInitialClient: config.delayBeforeRegisteringInitialClient, storePaths: OidcManager.storePathsFrom(config.dbPath) } const oidc = new OidcManager(options) oidc.validate() oidc.initMultiRpClient() oidc.initRs() oidc.initUserStore() oidc.initProvider() return oidc } static storePathsFrom (dbPath = DEFAULT_DB_PATH) { // Assuming dbPath = 'db/oidc' return { // RelyingParty client store path (results in 'db/oidc/rp/clients') multiRpStore: path.resolve(dbPath, 'rp'), // User store path (results in 'db/oidc/user/['users', 'users-by-email']) userStore: path.resolve(dbPath, 'users'), // Identity Provider store path (db/oidc/op/['codes', 'clients', 'tokens', 'refresh']) providerStore: path.resolve(dbPath, 'op') } } validate () { if (!this.serverUri) { throw new Error('serverUri is required') } if (!this.authCallbackUri) { throw new Error('authCallbackUri is required') } if (!this.postLogoutUri) { throw new Error('postLogoutUri is required') } } /** * Initializes on-disk resources required for OidcManager operation * (creates the various storage directories), and generates the provider's * crypto keychain (either from a previously generated and serialized config, * or from scratch). * * @return {Promise<RelyingParty>} Initialized local RP client */ initialize () { return Promise.resolve() .then(() => { this.initStorage() return this.initProviderKeychain() }) .then(() => { this.saveProviderConfig() // Use-case: if solid server is deployed behind a load-balancer // (e.g. F5, Nginx) there may be a delay between // when the server starting up and the load balancer detected that it's // up, which would cause failures when the // solid server is trying to register an **its own local** initial // RP client (i.e. the it won't see itself and // we'll get an ECONNRESET error!). if (this.delayBeforeRegisteringInitialClient) { this.debug(`Sleeping for ${this.delayBeforeRegisteringInitialClient} milliseconds`) return new Promise(resolve => setTimeout(resolve, this.delayBeforeRegisteringInitialClient)) } else { this.debug('Not sleeping before client registration...') } }) .then(() => this.initLocalRpClient()) .catch(this.debug) } /** * Initializes storage collections (creates directories if using * on-disk stores, etc). * Synchronous. */ initStorage () { this.clients.store.backend.initCollections() this.provider.backend.initCollections() this.users.initCollections() } initProviderKeychain () { if (this.provider.keys) { this.debug('Provider keys loaded from config') } else { this.debug('No provider keys found, generating fresh ones') } return this.provider.initializeKeyChain(this.provider.keys) .then(keys => { this.debug('Provider keychain initialized') }) } /** * Initializes the local Relying Party client (registered to this instance's * Provider). This acts as a cache warm up (proactively registers or loads * the client from saved config) so that the first API request that comes * along doesn't have to pause to do this. More importantly, it's used * to store the local RP client for use by the User * Consent screen and other components. * * @return {Promise<RelyingParty>} */ initLocalRpClient () { return this.clients.clientForIssuer(this.serverUri) .then(localClient => { this.debug('Local RP client initialized') this.localRp = localClient return localClient }) .catch(error => { this.debug('Error initializing local RP client: ', error) }) } initMultiRpClient () { const localRPConfig = { issuer: this.providerUri, redirect_uri: this.authCallbackUri, post_logout_redirect_uris: [this.postLogoutUri] } const backend = new KVPFileStore({ path: this.storePaths.multiRpStore, collections: ['clients'] }) const clientOptions = { backend, debug: this.debug, localConfig: localRPConfig } this.clients = new MultiRpClient(clientOptions) } initRs () { const rsConfig = { // oidc-rs defaults: { tokenTypesSupported: ['legacyPop', 'dpop'], handleErrors: false, optional: true, query: true, realm: this.serverUri, allow: { // Restrict token audience to either this serverUri or its subdomain audience: (aud) => this.filterAudience(aud) } } } this.rs = new ResourceAuthenticator(rsConfig) } initUserStore () { const userStoreConfig = { saltRounds: this.saltRounds, path: this.storePaths.userStore } this.users = UserStore.from(userStoreConfig) } initProvider () { const providerConfig = this.loadProviderConfig() const provider = new OIDCProvider(providerConfig) if (providerConfig.keys) { provider.keys = providerConfig.keys } const backend = new KVPFileStore({ path: this.storePaths.providerStore, collections: ['codes', 'clients', 'tokens', 'refresh'] }) provider.inject({ backend }) // Init the injected host API (authenticate / obtainConsent / logout) let host = this.host || {} host = Object.assign(host, HostAPI) provider.inject({ host }) this.provider = provider } providerConfigPath () { const storePath = this.storePaths.providerStore return path.join(storePath, 'provider.json') } /** * Returns a previously serialized Provider config if one is available on disk, * otherwise returns a minimal config object (with just the `issuer` set). * * @return {Object} */ loadProviderConfig () { let providerConfig = {} const configPath = this.providerConfigPath() const storedConfig = this.loadConfigFrom(configPath) if (storedConfig) { providerConfig = JSON.parse(storedConfig) } else { providerConfig.issuer = this.providerUri providerConfig.serverUri = this.serverUri } return providerConfig } /** * Loads a provider config from a given path * * @param path {string} * * @return {string} */ loadConfigFrom (path) { let storedConfig try { storedConfig = fs.readFileSync(path, 'utf8') } catch (error) { if (error.code !== 'ENOENT') { this.debug('Error in loadConfigFrom: ', error) throw error } } return storedConfig } saveProviderConfig () { const configPath = this.providerConfigPath() fs.writeFileSync(configPath, JSON.stringify(this.provider, null, 2)) } /** * Extracts and verifies the Web ID URI from a set of claims (from the payload * of a bearer token). * * @see https://github.com/solid/webid-oidc-spec#webid-provider-confirmation * * @param claims {Object} Claims hashmap, typically the payload of a decoded * ID Token. * * @throws {Error} * * @returns {Promise<string|null>} */ webIdFromClaims (claims) { if (!claims) { return Promise.resolve(null) } const webId = OidcManager.extractWebId(claims) const issuer = claims.iss const webidFromIssuer = OidcManager.domainMatches(issuer, webId) if (webidFromIssuer) { // easy case, issuer is in charge of the web id return Promise.resolve(webId) } // Otherwise, verify that issuer is the preferred OIDC provider for the web id return discoverProviderFor(webId) .then(preferredProvider => { if (preferredProvider === issuer) { // everything checks out return webId } throw new Error(`Preferred provider for Web ID ${webId} does not match token issuer ${issuer}`) }) } /** * Extracts the Web ID URI from a set of claims (from the payload of a bearer * token). * * @see https://github.com/solid/webid-oidc-spec#deriving-webid-uri-from-id-token * * @param claims {Object} Claims hashmap, typically the payload of a decoded * ID Token. * * @param claims.iss {string} * @param claims.sub {string} * * @param [claims.webid] {string} * * @throws {Error} * * @returns {string|null} */ static extractWebId (claims) { let webId if (!claims) { throw new Error('Cannot extract Web ID from missing claims') } const issuer = claims.iss if (!issuer) { throw new Error('Cannot extract Web ID - missing issuer claim') } if (!claims.webid && !claims.sub) { throw new Error('Cannot extract Web ID - no webid or subject claim') } if (claims.webid) { webId = claims.webid } else { // Look to the subject claim to extract a webid uri if (validUrl.isUri(claims.sub)) { webId = claims.sub } else { throw new Error('Cannot extract Web ID - subject claim is not a valid URI') } } return webId } filterAudience (aud) { if (!Array.isArray(aud)) { aud = [aud] } return aud.some(a => OidcManager.domainMatches(this.serverUri, a)) } /** * Tests whether a given Web ID uri belongs to the issuer. They must be: * - either from the same domain origin * - or the webid is an immediate subdomain of the issuer domain * * @param issuer {string} * @param webId {string} * * @returns {boolean} */ static domainMatches (issuer, webId) { let match try { webId = new URL(webId) const webIdOrigin = webId.origin // drop the path match = (issuer === webIdOrigin) || OidcManager.isSubdomain(webIdOrigin, issuer) } catch (err) { match = false } return match } /** * @param subdomain {string} e.g. Web ID origin (https://alice.example.com) * @param domain {string} e.g. Issuer domain (https://example.com) * * @returns {boolean} */ static isSubdomain (subdomain, domain) { subdomain = new URL(subdomain) domain = new URL(domain) if (subdomain.protocol !== domain.protocol) { return false // protocols must match } subdomain = subdomain.host // hostname + port, minus the protocol domain = domain.host // Chop off the first subdomain (alice.databox.me -> databox.me) const fragments = subdomain.split('.') fragments.shift() const abridgedSubdomain = fragments.join('.') return abridgedSubdomain === domain } } module.exports = OidcManager module.exports.DEFAULT_DB_PATH = DEFAULT_DB_PATH