UNPKG

@strongnguyen/oidc-provider

Version:

OAuth 2.0 Authorization Server implementation for Node.js with OpenID Connect

418 lines (325 loc) 13 kB
// eslint-disable-next-line import/order const attention = require('./helpers/attention'); const minimal = 'Erbium'; const current = process.release.lts; if (!current || current.charCodeAt(0) < minimal.charCodeAt(0)) { attention.warn('Unsupported Node.js runtime version.'); } const url = require('url'); const { strict: assert } = require('assert'); const { IncomingMessage, ServerResponse } = require('http'); const { Http2ServerRequest, Http2ServerResponse } = require('http2'); const events = require('events'); const Koa = require('koa'); const Configuration = require('./helpers/configuration'); const { ROUTER_URL_METHOD } = require('./helpers/symbols'); const instance = require('./helpers/weak_cache'); const initializeKeystore = require('./helpers/initialize_keystore'); const initializeAdapter = require('./helpers/initialize_adapter'); const initializeApp = require('./helpers/initialize_app'); const initializeClients = require('./helpers/initialize_clients'); const RequestUriCache = require('./helpers/request_uri_cache'); const ResourceServer = require('./helpers/resource_server'); const validUrl = require('./helpers/valid_url'); const epochTime = require('./helpers/epoch_time'); const getClaims = require('./helpers/claims'); const getContext = require('./helpers/oidc_context'); const { SessionNotFound, OIDCProviderError } = require('./helpers/errors'); const models = require('./models'); const ssHandler = require('./helpers/samesite_handler'); const get = require('./helpers/_/get'); function assertReqRes(req, res) { assert( req instanceof IncomingMessage || req instanceof Http2ServerRequest, 'first argument must be the request (http.IncomingMessage || http2.Http2ServerRequest), for express req, for koa ctx.req', ); if (arguments.length === 2) { assert( res instanceof ServerResponse || res instanceof Http2ServerResponse, 'second argument must be the response (http.ServerResponse || http2.Http2ServerResponse), for express res, for koa ctx.res', ); } } async function getInteraction(req, res) { assertReqRes.apply(undefined, arguments); // eslint-disable-line prefer-spread, prefer-rest-params const ctx = this.app.createContext(req, res); const id = ssHandler.get( ctx.cookies, this.cookieName('interaction'), instance(this).configuration('cookies.short'), ); if (!id) { throw new SessionNotFound('interaction session id cookie not found'); } const interaction = await this.Interaction.find(id); if (!interaction) { throw new SessionNotFound('interaction session not found'); } if (interaction.session && interaction.session.uid) { const session = await this.Session.findByUid(interaction.session.uid); if (!session) { throw new SessionNotFound('session not found'); } if (interaction.session.accountId !== session.accountId) { throw new SessionNotFound('session principal changed'); } } return interaction; } class Provider extends events.EventEmitter { #AccessToken; #Account; #app = new Koa(); #AuthorizationCode; #BaseToken; #Claims; #Client; #ClientCredentials; #DeviceCode; #BackchannelAuthenticationRequest; #Grant; #IdToken; #InitialAccessToken; #Interaction; #mountPath; #OIDCContext; #PushedAuthorizationRequest; #RefreshToken; #RegistrationAccessToken; #ReplayDetection; #Session; constructor(issuer, setup) { assert(issuer, 'first argument must be the Issuer Identifier, i.e. https://op.example.com'); assert.equal(typeof issuer, 'string', 'Issuer Identifier must be a string'); assert(validUrl.isWebUri(issuer), 'Issuer Identifier must be a valid web uri'); const components = url.parse(issuer); assert(components.host, 'Issuer Identifier must have a host component'); assert(components.protocol, 'Issuer Identifier must have an URI scheme component'); assert(!components.search, 'Issuer Identifier must not have a query component'); assert(!components.hash, 'Issuer Identifier must not have a fragment component'); super(); this.issuer = issuer; const configuration = new Configuration(setup); instance(this).configuration = (path) => { if (path) return get(configuration, path); return configuration; }; if (Array.isArray(configuration.cookies.keys) && configuration.cookies.keys.length) { this.#app.keys = configuration.cookies.keys; } else { attention.warn('configuration cookies.keys is missing, this option is critical to detect and ignore tampered cookies'); } instance(this).responseModes = new Map(); instance(this).grantTypeHandlers = new Map(); instance(this).grantTypeDupes = new Map(); instance(this).grantTypeParams = new Map([[undefined, new Set()]]); this.#Account = { findAccount: configuration.findAccount }; this.#Claims = getClaims(this); instance(this).BaseModel = models.getBaseModel(this); this.#BaseToken = models.getBaseToken(this); this.#IdToken = models.getIdToken(this); this.#Client = models.getClient(this); this.#Grant = models.getGrant(this); this.#Session = models.getSession(this); this.#Interaction = models.getInteraction(this); this.#AccessToken = models.getAccessToken(this); this.#AuthorizationCode = models.getAuthorizationCode(this); this.#RefreshToken = models.getRefreshToken(this); this.#ClientCredentials = models.getClientCredentials(this); this.#InitialAccessToken = models.getInitialAccessToken(this); this.#RegistrationAccessToken = models.getRegistrationAccessToken(this); this.#ReplayDetection = models.getReplayDetection(this); this.#DeviceCode = models.getDeviceCode(this); this.#BackchannelAuthenticationRequest = models.getBackchannelAuthenticationRequest(this); this.#PushedAuthorizationRequest = models.getPushedAuthorizationRequest(this); this.#OIDCContext = getContext(this); const { pathname } = url.parse(this.issuer); this.#mountPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; instance(this).requestUriCache = new RequestUriCache(this); initializeAdapter.call(this, configuration.adapter); initializeKeystore.call(this, configuration.jwks); delete configuration.jwks; initializeApp.call(this); initializeClients.call(this, configuration.clients); delete configuration.clients; } urlFor(name, opt) { return url.resolve(this.issuer, this.pathFor(name, opt)); } registerGrantType(name, handler, params, dupes) { instance(this).configuration('grantTypes').add(name); const { grantTypeHandlers, grantTypeParams, grantTypeDupes } = instance(this); const grantParams = new Set(['grant_type']); grantTypeHandlers.set(name, handler); if (dupes && typeof dupes === 'string') { grantTypeDupes.set(name, new Set([dupes])); } else if (dupes && (Array.isArray(dupes) || dupes instanceof Set)) { grantTypeDupes.set(name, new Set(dupes)); } if (params && typeof params === 'string') { grantParams.add(params); } else if (params && (Array.isArray(params) || params instanceof Set)) { params.forEach(Set.prototype.add.bind(grantParams)); } const defaultParams = instance(this).configuration('grantTypeParamsDefault'); if (defaultParams && Array.isArray(defaultParams) && defaultParams.length > 0) { defaultParams.forEach(Set.prototype.add.bind(grantParams)); } grantTypeParams.set(name, grantParams); grantParams.forEach(Set.prototype.add.bind(grantTypeParams.get(undefined))); } cookieName(type) { const name = instance(this).configuration(`cookies.names.${type}`); assert(name, `cookie name for type ${type} is not configured`); const prefix = instance(this).configuration('cookies.prefix'); if (!prefix) return name; return `${prefix}.${name}`; } registerResponseMode(name, handler) { const { responseModes } = instance(this); if (!responseModes.has(name)) { responseModes.set(name, handler.bind(this)); } } pathFor(name, { mountPath = this.#mountPath, ...opts } = {}) { const { router } = instance(this); const routerUrl = router[ROUTER_URL_METHOD](name, opts); return [mountPath, routerUrl].join(''); } /** * @name interactionResult * @api public */ async interactionResult(req, res, result, { mergeWithLastSubmission = true } = {}) { const interaction = await getInteraction.call(this, req, res); if (mergeWithLastSubmission && !('error' in result)) { interaction.result = { ...interaction.lastSubmission, ...result }; } else { interaction.result = result; } await interaction.save(interaction.exp - epochTime()); return interaction.returnTo; } /** * @name interactionFinished * @api public */ async interactionFinished(req, res, result, { mergeWithLastSubmission = true } = {}) { const returnTo = await this.interactionResult(req, res, result, { mergeWithLastSubmission }); res.statusCode = 303; // eslint-disable-line no-param-reassign res.setHeader('Location', returnTo); res.setHeader('Content-Length', '0'); res.end(); } /** * @name interactionDetails * @api public */ async interactionDetails(req, res) { return getInteraction.call(this, req, res); } async backchannelResult(request, result, { acr, amr, authTime, sessionUid, expiresWithSession, sid, } = {}) { if (typeof request === 'string' && request) { // eslint-disable-next-line no-param-reassign request = await this.#BackchannelAuthenticationRequest.find(request, { ignoreExpiration: true, }); if (!request) { throw new Error('BackchannelAuthenticationRequest not found'); } } else if (!(request instanceof this.#BackchannelAuthenticationRequest)) { throw new TypeError('invalid "request" argument'); } const client = await this.#Client.find(request.clientId); if (!client) { throw new Error('Client not found'); } if (typeof result === 'string' && result) { // eslint-disable-next-line no-param-reassign result = await this.#Grant.find(result); if (!result) { throw new Error('Grant not found'); } } switch (true) { case result instanceof this.#Grant: if (request.clientId !== result.clientId) { throw new Error('client mismatch'); } if (request.accountId !== result.accountId) { throw new Error('accountId mismatch'); } Object.assign(request, { grantId: result.jti, acr, amr, authTime, sessionUid, expiresWithSession, sid, }); break; case result instanceof OIDCProviderError: Object.assign(request, { error: result.error, error_description: result.error_description, }); break; default: throw new TypeError('invalid "result" argument'); } await request.save(); if (client.backchannelTokenDeliveryMode === 'ping') { await client.backchannelPing(request); } } use(fn) { this.#app.use(fn); // note: get the fn back since it might've been changed from generator to fn by koa-convert const newMw = this.#app.middleware.pop(); const internalIndex = this.#app.middleware.findIndex((mw) => !!mw.firstInternal); this.#app.middleware.splice(internalIndex, 0, newMw); } get app() { return this.#app; } callback() { return this.#app.callback(); } listen(...args) { return this.#app.listen(...args); } get proxy() { return this.#app.proxy; } set proxy(value) { this.#app.proxy = value; } get OIDCContext() { return this.#OIDCContext; } get Claims() { return this.#Claims; } get BaseToken() { return this.#BaseToken; } get Account() { return this.#Account; } get IdToken() { return this.#IdToken; } get Client() { return this.#Client; } get Grant() { return this.#Grant; } get Session() { return this.#Session; } get Interaction() { return this.#Interaction; } get AccessToken() { return this.#AccessToken; } get AuthorizationCode() { return this.#AuthorizationCode; } get RefreshToken() { return this.#RefreshToken; } get ClientCredentials() { return this.#ClientCredentials; } get InitialAccessToken() { return this.#InitialAccessToken; } get RegistrationAccessToken() { return this.#RegistrationAccessToken; } get DeviceCode() { return this.#DeviceCode; } get BackchannelAuthenticationRequest() { return this.#BackchannelAuthenticationRequest; } get PushedAuthorizationRequest() { return this.#PushedAuthorizationRequest; } get ReplayDetection() { return this.#ReplayDetection; } // eslint-disable-next-line class-methods-use-this get ResourceServer() { return ResourceServer; } } module.exports = Provider;