From 8f08b94f74be620e444545490ab37b2da36657a0 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 29 Mar 2022 01:14:46 -0500 Subject: [PATCH] Error response enhancements, CoreID auth client backend --- package.json | 2 + pnpm-lock.yaml | 85 ++++++++++++++++- src/auth/Authentication.ts | 2 +- src/auth/context/SecurityContext.ts | 17 +++- src/auth/index.ts | 5 + src/auth/middleware/AuthRequiredMiddleware.ts | 4 +- src/auth/provider/LoginProvider.ts | 10 +- .../provider/oauth/CoreIDLoginProvider.ts | 1 + src/auth/server/OAuth2Server.ts | 86 ++++++++++++++++- src/auth/server/models/OAuth2TokenModel.ts | 30 ++++++ .../CacheRedemptionCodeRepository.ts | 33 +++++++ .../server/repositories/ORMTokenRepository.ts | 88 ++++++++++++++++++ .../RedemptionCodeRepositoryFactory.ts | 74 +++++++++++++++ .../repositories/TokenRepositoryFactory.ts | 74 +++++++++++++++ src/auth/server/types.ts | 93 ++++++++++++++++++- src/di/Container.ts | 19 ++-- .../ExecuteResolvedRouteHandlerHTTPModule.ts | 14 ++- ...ExecuteResolvedRoutePreflightHTTPModule.ts | 14 +-- src/http/lifecycle/Response.ts | 4 + src/http/response/ErrorResponseFactory.ts | 13 ++- src/http/response/RedirectResponseFactory.ts | 4 +- src/http/routing/Route.ts | 8 +- src/http/routing/RouteGroup.ts | 16 ++-- src/http/session/Session.ts | 7 +- src/lifecycle/Application.ts | 4 +- src/orm/model/Model.ts | 2 +- src/resources/views/oauth2/authorize.pug | 11 ++- src/service/Canonical.ts | 14 ++- src/util/cache/Cache.ts | 9 ++ src/util/error/ErrorWithContext.ts | 22 +++++ src/util/support/Safe.ts | 23 ++++- 31 files changed, 736 insertions(+), 52 deletions(-) create mode 100644 src/auth/server/models/OAuth2TokenModel.ts create mode 100644 src/auth/server/repositories/CacheRedemptionCodeRepository.ts create mode 100644 src/auth/server/repositories/ORMTokenRepository.ts create mode 100644 src/auth/server/repositories/RedemptionCodeRepositoryFactory.ts create mode 100644 src/auth/server/repositories/TokenRepositoryFactory.ts diff --git a/package.json b/package.json index c91a286..bb9c66b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/busboy": "^0.2.3", "@types/cli-table": "^0.3.0", "@types/ioredis": "^4.26.6", + "@types/jsonwebtoken": "^8.5.8", "@types/mime-types": "^2.1.0", "@types/mkdirp": "^1.0.1", "@types/negotiator": "^0.6.1", @@ -30,6 +31,7 @@ "colors": "^1.4.0", "dotenv": "^8.2.0", "ioredis": "^4.27.6", + "jsonwebtoken": "^8.5.1", "mime-types": "^2.1.31", "mkdirp": "^1.0.4", "negotiator": "^0.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28cf466..8cce976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ specifiers: '@types/chai': ^4.2.22 '@types/cli-table': ^0.3.0 '@types/ioredis': ^4.26.6 + '@types/jsonwebtoken': ^8.5.8 '@types/mime-types': ^2.1.0 '@types/mkdirp': ^1.0.1 '@types/mocha': ^9.0.0 @@ -30,6 +31,7 @@ specifiers: dotenv: ^8.2.0 eslint: ^7.27.0 ioredis: ^4.27.6 + jsonwebtoken: ^8.5.1 mime-types: ^2.1.31 mkdirp: ^1.0.4 mocha: ^9.1.3 @@ -57,6 +59,7 @@ dependencies: '@types/busboy': 0.2.3 '@types/cli-table': 0.3.0 '@types/ioredis': 4.26.6 + '@types/jsonwebtoken': 8.5.8 '@types/mime-types': 2.1.0 '@types/mkdirp': 1.0.1 '@types/negotiator': 0.6.1 @@ -73,6 +76,7 @@ dependencies: colors: 1.4.0 dotenv: 8.2.0 ioredis: 4.27.6 + jsonwebtoken: 8.5.1 mime-types: 2.1.31 mkdirp: 1.0.4 negotiator: 0.6.2 @@ -338,6 +342,12 @@ packages: resolution: {integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==} dev: true + /@types/jsonwebtoken/8.5.8: + resolution: {integrity: sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A==} + dependencies: + '@types/node': 14.17.6 + dev: false + /@types/mime-types/2.1.0: resolution: {integrity: sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=} dev: false @@ -782,6 +792,10 @@ packages: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} dev: true + /buffer-equal-constant-time/1.0.1: + resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=} + dev: false + /buffer-from/1.1.1: resolution: {integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==} dev: false @@ -1132,6 +1146,12 @@ packages: engines: {node: '>=8'} dev: false + /ecdsa-sig-formatter/1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /emoji-regex/8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1770,6 +1790,22 @@ packages: graceful-fs: 4.2.6 dev: false + /jsonwebtoken/8.5.1: + resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} + engines: {node: '>=4', npm: '>=1.4.28'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 5.7.1 + dev: false + /jstransformer/1.0.0: resolution: {integrity: sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=} dependencies: @@ -1781,6 +1817,21 @@ packages: resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} dev: true + /jwa/1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws/3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /levn/0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1812,10 +1863,38 @@ packages: resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=} dev: true + /lodash.includes/4.3.0: + resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=} + dev: false + + /lodash.isboolean/3.0.3: + resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=} + dev: false + + /lodash.isinteger/4.0.4: + resolution: {integrity: sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=} + dev: false + + /lodash.isnumber/3.0.3: + resolution: {integrity: sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=} + dev: false + + /lodash.isplainobject/4.0.6: + resolution: {integrity: sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=} + dev: false + + /lodash.isstring/4.0.1: + resolution: {integrity: sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=} + dev: false + /lodash.merge/4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.once/4.1.1: + resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=} + dev: false + /lodash.truncate/4.4.2: resolution: {integrity: sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=} dev: true @@ -1959,7 +2038,6 @@ packages: /ms/2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true /mute-stream/0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -2508,6 +2586,11 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false + /semver/5.7.1: + resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} + hasBin: true + dev: false + /semver/6.3.0: resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} hasBin: true diff --git a/src/auth/Authentication.ts b/src/auth/Authentication.ts index 31f23b4..68864cb 100644 --- a/src/auth/Authentication.ts +++ b/src/auth/Authentication.ts @@ -61,7 +61,7 @@ export class Authentication extends Unit { Route.group(`/auth/${name}`, () => { this.providers[name].routes() - }).pre(request => request.make(middleware, request).apply()) + }).pre(middleware) } } diff --git a/src/auth/context/SecurityContext.ts b/src/auth/context/SecurityContext.ts index 407ad35..32765a2 100644 --- a/src/auth/context/SecurityContext.ts +++ b/src/auth/context/SecurityContext.ts @@ -1,10 +1,11 @@ import {Inject, Injectable} from '../../di' -import {Awaitable, Maybe} from '../../util' +import {Awaitable, HTTPStatus, Maybe} from '../../util' import {Authenticatable, AuthenticatableRepository} from '../types' import {Logging} from '../../service/Logging' import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent' import {UserFlushedEvent} from '../event/UserFlushedEvent' import {Bus} from '../../support/bus' +import {HTTPError} from '../../http/HTTPError' /** * Base-class for a context that authenticates users and manages security. @@ -95,6 +96,20 @@ export abstract class SecurityContext { return this.authenticatedUser } + /** Get the current user or throw an authorization error. */ + user(): Authenticatable { + if ( !this.hasUser() ) { + throw new HTTPError(HTTPStatus.UNAUTHORIZED) + } + + const user = this.getUser() + if ( !user ) { + throw new HTTPError(HTTPStatus.UNAUTHORIZED) + } + + return user + } + /** * Returns true if there is a currently authenticated user. */ diff --git a/src/auth/index.ts b/src/auth/index.ts index a98e2ca..d2d5c09 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -30,8 +30,13 @@ export * from './repository/orm/ORMUserRepository' export * from './config' export * from './server/types' +export * from './server/models/OAuth2TokenModel' export * from './server/repositories/ConfigClientRepository' export * from './server/repositories/ConfigScopeRepository' export * from './server/repositories/ClientRepositoryFactory' export * from './server/repositories/ScopeRepositoryFactory' +export * from './server/repositories/ORMTokenRepository' +export * from './server/repositories/TokenRepositoryFactory' +export * from './server/repositories/CacheRedemptionCodeRepository' +export * from './server/repositories/RedemptionCodeRepositoryFactory' export * from './server/OAuth2Server' diff --git a/src/auth/middleware/AuthRequiredMiddleware.ts b/src/auth/middleware/AuthRequiredMiddleware.ts index cc1fd27..fdc95d0 100644 --- a/src/auth/middleware/AuthRequiredMiddleware.ts +++ b/src/auth/middleware/AuthRequiredMiddleware.ts @@ -26,8 +26,8 @@ export class AuthRequiredMiddleware extends Middleware { if ( !this.security.hasUser() ) { this.session.set('@extollo:auth.intention', this.request.url) - if ( this.routing.hasNamedRoute('@auth.login') ) { - return redirect(this.routing.getNamedPath('@auth.login').toRemote) + if ( this.routing.hasNamedRoute('@auth:login') ) { + return redirect(this.routing.getNamedPath('@auth:login').toRemote) } else { return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) } diff --git a/src/auth/provider/LoginProvider.ts b/src/auth/provider/LoginProvider.ts index 8aa3d9e..735cc87 100644 --- a/src/auth/provider/LoginProvider.ts +++ b/src/auth/provider/LoginProvider.ts @@ -6,6 +6,7 @@ import {Inject, Injectable} from '../../di' import {SecurityContext} from '../context/SecurityContext' import {redirect} from '../../http/response/RedirectResponseFactory' import {RequestLocalStorage} from '../../http/RequestLocalStorage' +import {Session} from '../../http/session/Session' export interface LoginProviderConfig { default: boolean, @@ -61,6 +62,13 @@ export abstract class LoginProvider { } protected redirectToIntendedRoute(): ResponseObject { - return redirect('/') // FIXME + const intent = this.request + .get() + .make(Session) + .safe('@extollo:auth.intention') + .or('/') + .string() + + return redirect(intent) } } diff --git a/src/auth/provider/oauth/CoreIDLoginProvider.ts b/src/auth/provider/oauth/CoreIDLoginProvider.ts index 0d65f01..0e2f87d 100644 --- a/src/auth/provider/oauth/CoreIDLoginProvider.ts +++ b/src/auth/provider/oauth/CoreIDLoginProvider.ts @@ -90,6 +90,7 @@ export class CoreIDLoginProvider extends OAuth2LoginProvider(OAuth2Server, x => x.promptForAuthorization.bind(x)) + .calls(OAuth2Server, x => x.promptForAuthorization) + + Route.post('/oauth2/authorize') + .alias('@oauth2:authorize:submit') + .pre(AuthRequiredMiddleware) + .passingRequest() + .calls(OAuth2Server, x => x.authorizeAndRedirect) + + Route.post('/oauth2/redeem') + .alias('@oauth2:authorize:redeem') + .passingRequest() + .calls(OAuth2Server, x => x.redeemToken) + } + + async redeemToken(request: Request): Promise { + const authParts = String(request.getHeader('Authorization')).split(':') + if ( authParts.length !== 2 ) { + throw new HTTPError(HTTPStatus.BAD_REQUEST) + } + + const clientRepo = request.make(ClientRepository) + const [clientId, clientSecret] = authParts + const client = await clientRepo.find(clientId) + if ( !client || client.secret !== clientSecret ) { + throw new HTTPError(HTTPStatus.UNAUTHORIZED) + } + + const codeRepo = request.make(RedemptionCodeRepository) + const codeString = request.safe('code').string() + const code = await codeRepo.find(codeString) + if ( !code ) { + throw new HTTPError(HTTPStatus.BAD_REQUEST) + } + + + } + + async authorizeAndRedirect(request: Request): Promise { + // Look up the client in the client repo + const session = request.make(Session) + const clientId = session.safe('oauth2.authorize.clientId').string() + const client = await this.getClient(request, clientId) + + const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows) + if ( flowType === OAuth2FlowType.code ) { + return this.authorizeCodeFlow(request, client) + } + } + + protected async authorizeCodeFlow(request: Request, client: OAuth2Client): Promise { + const session = request.make(Session) + const security = request.make(SecurityContext) + const codeRepository = request.make(RedemptionCodeRepository) + + const user = security.user() + const scope = session.get('oauth2.authorize.scope') + const redirectUri = session.safe('oauth2.authorize.redirectUri').in(client.allowedRedirectUris) + + // FIXME store authorization + + const code = await codeRepository.issue(user, client, scope) + const uri = new URL(redirectUri) + uri.searchParams.set('code', code.code) + return redirect(uri) } async promptForAuthorization(request: Request): Promise { // Look up the client in the client repo - const client = await this.getClient(request) + const clientId = request.safe('client_id').string() + const client = await this.getClient(request, clientId) // Make sure the requested flow type is valid for this client const session = request.make(Session) @@ -43,12 +119,12 @@ export class OAuth2Server extends Controller { return view('@extollo:oauth2:authorize', { clientName: client.display, scopeDescription: scope?.description, + redirectDomain: (new URL(redirectUri)).host, }) } - protected async getClient(request: Request): Promise { + protected async getClient(request: Request, clientId: string): Promise { const clientRepo = request.make(ClientRepository) - const clientId = request.safe('client_id').string() const client = await clientRepo.find(clientId) if ( !client ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', { diff --git a/src/auth/server/models/OAuth2TokenModel.ts b/src/auth/server/models/OAuth2TokenModel.ts new file mode 100644 index 0000000..1cb6d24 --- /dev/null +++ b/src/auth/server/models/OAuth2TokenModel.ts @@ -0,0 +1,30 @@ +import {Field, FieldType, Model} from '../../../orm' +import {OAuth2Token} from '../types' + +export class OAuth2TokenModel extends Model implements OAuth2Token { + public static table = 'oauth2_tokens' + + public static key = 'oauth2_token_id' + + @Field(FieldType.serial, 'oauth2_token_id') + protected oauth2TokenId!: number + + public get id(): string { + return String(this.oauth2TokenId) + } + + @Field(FieldType.varchar, 'user_id') + public userId!: string + + @Field(FieldType.varchar, 'client_id') + public clientId!: string + + @Field(FieldType.timestamp) + public issued!: Date + + @Field(FieldType.timestamp) + public expires!: Date + + @Field(FieldType.varchar) + public scope?: string +} diff --git a/src/auth/server/repositories/CacheRedemptionCodeRepository.ts b/src/auth/server/repositories/CacheRedemptionCodeRepository.ts new file mode 100644 index 0000000..11b4a55 --- /dev/null +++ b/src/auth/server/repositories/CacheRedemptionCodeRepository.ts @@ -0,0 +1,33 @@ +import {isOAuth2RedemptionCode, OAuth2Client, OAuth2RedemptionCode, RedemptionCodeRepository} from '../types' +import {Inject, Injectable} from '../../../di' +import {Cache, Maybe, uuid4} from '../../../util' +import {Authenticatable} from '../../types' + +@Injectable() +export class CacheRedemptionCodeRepository extends RedemptionCodeRepository { + @Inject() + protected readonly cache!: Cache + + async find(codeString: string): Promise> { + const cacheKey = `@extollo:oauth2:redemption:${codeString}` + if ( await this.cache.has(cacheKey) ) { + const code = await this.cache.safe(cacheKey).then(x => x.json()) + if ( isOAuth2RedemptionCode(code) ) { + return code + } + } + } + + async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise { + const code = { + scope, + clientId: client.id, + userId: user.getUniqueIdentifier(), + code: uuid4(), + } + + const cacheKey = `@extollo:oauth2:redemption:${code.code}` + await this.cache.put(cacheKey, JSON.stringify(code)) + return code + } +} diff --git a/src/auth/server/repositories/ORMTokenRepository.ts b/src/auth/server/repositories/ORMTokenRepository.ts new file mode 100644 index 0000000..675f395 --- /dev/null +++ b/src/auth/server/repositories/ORMTokenRepository.ts @@ -0,0 +1,88 @@ +import {isOAuth2Token, OAuth2Client, OAuth2Token, oauth2TokenString, OAuth2TokenString, TokenRepository} from '../types' +import {Inject, Injectable} from '../../../di' +import {Maybe} from '../../../util' +import {OAuth2TokenModel} from '../models/OAuth2TokenModel' +import {Config} from '../../../service/Config' +import * as jwt from 'jsonwebtoken' +import {Authenticatable} from '../../types' + +@Injectable() +export class ORMTokenRepository extends TokenRepository { + @Inject() + protected readonly config!: Config + + async find(id: string): Promise> { + const idNum = parseInt(id, 10) + if ( !isNaN(idNum) ) { + return OAuth2TokenModel.query() + .whereKey(idNum) + .first() + } + } + + async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise { + const expiration = this.config.safe('outh2.token.lifetimeSeconds') + .or(60 * 60 * 6) + .integer() * 1000 + + const token = new OAuth2TokenModel() + token.scope = scope + token.userId = String(user.getUniqueIdentifier()) + token.clientId = client.id + token.issued = new Date() + token.expires = new Date(Math.floor(Date.now() + expiration)) + await token.save() + + return token + } + + async encode(token: OAuth2Token): Promise { + const secret = this.config.safe('oauth2.secret').string() + const payload = { + id: token.id, + userId: token.userId, + clientId: token.clientId, + iat: Math.floor(token.issued.valueOf() / 1000), + exp: Math.floor(token.expires.valueOf() / 1000), + ...(token.scope ? { scope: token.scope } : {}), + } + + const generated = await new Promise((res, rej) => { + jwt.sign(payload, secret, {}, (err, gen) => { + if (err || err === null || !gen) { + rej(err || new Error('Unable to encode JWT.')) + } else { + res(gen) + } + }) + }) + + return oauth2TokenString(generated) + } + + async decode(token: OAuth2TokenString): Promise> { + const secret = this.config.safe('oauth2.secret').string() + const decoded = await new Promise((res, rej) => { + jwt.verify(token, secret, {}, (err, payload) => { + if ( err ) { + rej(err) + } else { + res(payload) + } + }) + }) + + const value = { + id: decoded.id, + userId: decoded.userId, + clientId: decoded.clientId, + issued: new Date(decoded.iat * 1000), + expires: new Date(decoded.exp * 1000), + ...(decoded.scope ? { scope: decoded.scope } : {}), + } + + if ( isOAuth2Token(value) ) { + return value + } + } +} diff --git a/src/auth/server/repositories/RedemptionCodeRepositoryFactory.ts b/src/auth/server/repositories/RedemptionCodeRepositoryFactory.ts new file mode 100644 index 0000000..2c2c9fa --- /dev/null +++ b/src/auth/server/repositories/RedemptionCodeRepositoryFactory.ts @@ -0,0 +1,74 @@ +import { + AbstractFactory, + Container, + DependencyRequirement, + PropertyDependency, + isInstantiable, + DEPENDENCY_KEYS_METADATA_KEY, + DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer, +} from '../../../di' +import {Collection, ErrorWithContext} from '../../../util' +import {Config} from '../../../service/Config' +import {RedemptionCodeRepository} from '../types' +import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository' + +/** + * A dependency injection factory that matches the abstract RedemptionCodeRepository class + * and produces an instance of the configured repository driver implementation. + */ +@FactoryProducer() +export class RedemptionCodeRepositoryFactory extends AbstractFactory { + protected get config(): Config { + return Container.getContainer().make(Config) + } + + produce(): RedemptionCodeRepository { + return new (this.getRedemptionCodeRepositoryClass())() + } + + match(something: unknown): boolean { + return something === RedemptionCodeRepository + } + + getDependencyKeys(): Collection { + const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getRedemptionCodeRepositoryClass()) + if ( meta ) { + return meta + } + return new Collection() + } + + getInjectedProperties(): Collection { + const meta = new Collection() + let currentToken = this.getRedemptionCodeRepositoryClass() + + do { + const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken) + if ( loadedMeta ) { + meta.concat(loadedMeta) + } + currentToken = Object.getPrototypeOf(currentToken) + } while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype) + + return meta + } + + /** + * Return the instantiable class of the configured client repository backend. + * @protected + * @return Instantiable + */ + protected getRedemptionCodeRepositoryClass(): Instantiable { + const RedemptionCodeRepositoryClass = this.config.get('oauth2.repository.client', CacheRedemptionCodeRepository) + + if ( !isInstantiable(RedemptionCodeRepositoryClass) || !(RedemptionCodeRepositoryClass.prototype instanceof RedemptionCodeRepository) ) { + const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.RedemptionCodeRepository') + e.context = { + configKey: 'oauth2.repository.client', + class: RedemptionCodeRepositoryClass.toString(), + } + } + + return RedemptionCodeRepositoryClass + } +} diff --git a/src/auth/server/repositories/TokenRepositoryFactory.ts b/src/auth/server/repositories/TokenRepositoryFactory.ts new file mode 100644 index 0000000..195e280 --- /dev/null +++ b/src/auth/server/repositories/TokenRepositoryFactory.ts @@ -0,0 +1,74 @@ +import { + AbstractFactory, + Container, + DependencyRequirement, + PropertyDependency, + isInstantiable, + DEPENDENCY_KEYS_METADATA_KEY, + DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer, +} from '../../../di' +import {Collection, ErrorWithContext} from '../../../util' +import {Config} from '../../../service/Config' +import {TokenRepository} from '../types' +import {ORMTokenRepository} from './ORMTokenRepository' + +/** + * A dependency injection factory that matches the abstract TokenRepository class + * and produces an instance of the configured repository driver implementation. + */ +@FactoryProducer() +export class TokenRepositoryFactory extends AbstractFactory { + protected get config(): Config { + return Container.getContainer().make(Config) + } + + produce(): TokenRepository { + return new (this.getTokenRepositoryClass())() + } + + match(something: unknown): boolean { + return something === TokenRepository + } + + getDependencyKeys(): Collection { + const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getTokenRepositoryClass()) + if ( meta ) { + return meta + } + return new Collection() + } + + getInjectedProperties(): Collection { + const meta = new Collection() + let currentToken = this.getTokenRepositoryClass() + + do { + const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken) + if ( loadedMeta ) { + meta.concat(loadedMeta) + } + currentToken = Object.getPrototypeOf(currentToken) + } while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype) + + return meta + } + + /** + * Return the instantiable class of the configured token repository backend. + * @protected + * @return Instantiable + */ + protected getTokenRepositoryClass(): Instantiable { + const TokenRepositoryClass = this.config.get('oauth2.repository.token', ORMTokenRepository) + + if ( !isInstantiable(TokenRepositoryClass) || !(TokenRepositoryClass.prototype instanceof TokenRepository) ) { + const e = new ErrorWithContext('Provided token repository class does not extend from @extollo/lib.TokenRepository') + e.context = { + configKey: 'oauth2.repository.client', + class: TokenRepositoryClass.toString(), + } + } + + return TokenRepositoryClass + } +} diff --git a/src/auth/server/types.ts b/src/auth/server/types.ts index 1f89edf..628fe89 100644 --- a/src/auth/server/types.ts +++ b/src/auth/server/types.ts @@ -1,4 +1,5 @@ -import {Awaitable, hasOwnProperty, Maybe} from '../../util' +import {Awaitable, hasOwnProperty, Maybe, TypeTag} from '../../util' +import {Authenticatable, AuthenticatableIdentifier} from '../types' export enum OAuth2FlowType { code = 'code', @@ -81,3 +82,93 @@ export abstract class ScopeRepository { abstract findByName(name: string): Awaitable> } + +export interface OAuth2Token { + id: string + userId: AuthenticatableIdentifier + clientId: string + issued: Date + expires: Date + scope?: string +} + +export type OAuth2TokenString = TypeTag<'@extollo/lib.OAuth2TokenString'> & string + +export function oauth2TokenString(s: string): OAuth2TokenString { + return s as OAuth2TokenString +} + +export function isOAuth2Token(what: unknown): what is OAuth2Token { + if ( typeof what !== 'object' || what === null ) { + return false + } + + if ( + !hasOwnProperty(what, 'id') + || !hasOwnProperty(what, 'userId') + || !hasOwnProperty(what, 'clientId') + || !hasOwnProperty(what, 'issued') + || !hasOwnProperty(what, 'expires') + ) { + return false + } + + if ( + typeof what.id !== 'string' + || !(typeof what.userId === 'string' || typeof what.userId === 'number') + || typeof what.clientId !== 'string' + || !(what.issued instanceof Date) + || !(what.expires instanceof Date) + ) { + return false + } + + return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string' +} + +export abstract class TokenRepository { + abstract find(id: string): Awaitable> + + abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable + + abstract decode(token: OAuth2TokenString): Awaitable> + + abstract encode(token: OAuth2Token): Awaitable +} + +export interface OAuth2RedemptionCode { + clientId: string + userId: AuthenticatableIdentifier + code: string + scope?: string +} + +export function isOAuth2RedemptionCode(what: unknown): what is OAuth2RedemptionCode { + if ( typeof what !== 'object' || what === null ) { + return false + } + + if ( + !hasOwnProperty(what, 'clientId') + || !hasOwnProperty(what, 'userId') + || !hasOwnProperty(what, 'code') + ) { + return false + } + + if ( + typeof what.clientId !== 'string' + || !(typeof what.userId === 'number' || typeof what.userId === 'string') + || typeof what.code !== 'string' + ) { + return false + } + + return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string' +} + +export abstract class RedemptionCodeRepository { + abstract find(code: string): Awaitable> + + abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable +} diff --git a/src/di/Container.ts b/src/di/Container.ts index 3a01ee6..53dd05e 100644 --- a/src/di/Container.ts +++ b/src/di/Container.ts @@ -451,14 +451,19 @@ export class Container { this.checkForMakeCycles() - if ( this.hasKey(target) ) { - const realized = this.resolveAndCreate(target, ...parameters) + try { + if (this.hasKey(target)) { + const realized = this.resolveAndCreate(target, ...parameters) + Container.makeStack.pop() + return realized + } else if (typeof target !== 'string' && isInstantiable(target)) { + const realized = this.produceFactory(new Factory(target), parameters) + Container.makeStack.pop() + return realized + } + } catch (e: unknown) { Container.makeStack.pop() - return realized - } else if ( typeof target !== 'string' && isInstantiable(target) ) { - const realized = this.produceFactory(new Factory(target), parameters) - Container.makeStack.pop() - return realized + throw e } Container.makeStack.pop() diff --git a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts index b52d85a..7e5f549 100644 --- a/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule.ts @@ -2,7 +2,7 @@ import {HTTPKernel} from '../HTTPKernel' import {Request} from '../../lifecycle/Request' import {ActivatedRoute} from '../../routing/ActivatedRoute' import {http} from '../../response/HTTPErrorResponseFactory' -import {HTTPStatus} from '../../../util' +import {HTTPStatus, withErrorContext} from '../../../util' import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule' /** @@ -23,11 +23,15 @@ export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRoute throw new Error('Attempted to call route handler without resolved parameters.') } - const result = await route.handler - .tap(handler => handler(...params)) - .apply(request) + await withErrorContext(async () => { + const result = await route.handler + .tap(handler => handler(...params)) + .apply(request) - await this.applyResponseObject(result, request) + await this.applyResponseObject(result, request) + }, { + route, + }) } else { await http(HTTPStatus.NOT_FOUND).write(request) request.response.blockingWriteback(true) diff --git a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts index 8ccafa9..5107ac7 100644 --- a/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts +++ b/src/http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule.ts @@ -4,7 +4,7 @@ import {Request} from '../../lifecycle/Request' import {ActivatedRoute} from '../../routing/ActivatedRoute' import {ResponseObject} from '../../routing/Route' import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule' -import {collect, isLeft, unleft, unright} from '../../../util' +import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util' /** * HTTP Kernel module that executes the preflight handlers for the route. @@ -22,11 +22,13 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou const preflight = route.preflight for ( const handler of preflight ) { - const result: ResponseObject = await handler(request) - if ( typeof result !== 'undefined' ) { - await this.applyResponseObject(result, request) - request.response.blockingWriteback(true) - } + await withErrorContext(async () => { + const result: ResponseObject = await handler(request) + if ( typeof result !== 'undefined' ) { + await this.applyResponseObject(result, request) + request.response.blockingWriteback(true) + } + }, { handler }) } const parameters = route.parameters diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index 8b44850..f0e35ab 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -195,6 +195,10 @@ export class Response { */ public async write(data: string | Buffer | Uint8Array | Readable): Promise { return new Promise((res, rej) => { + if ( this.responseEnded ) { + throw new ErrorWithContext('Tried to write to Response after lifecycle ended.') + } + if ( !this.sentHeaders ) { this.sendHeaders() } diff --git a/src/http/response/ErrorResponseFactory.ts b/src/http/response/ErrorResponseFactory.ts index 52a867a..400a680 100644 --- a/src/http/response/ErrorResponseFactory.ts +++ b/src/http/response/ErrorResponseFactory.ts @@ -72,9 +72,12 @@ export class ErrorResponseFactory extends ResponseFactory { } } + const suggestion = this.getSuggestion() + let str = ` Sorry, an unexpected error occurred while processing your request.
+ ${suggestion ? '
Suggestion: ' + suggestion + '
' : ''}

 Name: ${thrownError.name}
 Message: ${thrownError.message}
@@ -88,7 +91,7 @@ Stack trace:
             str += `
                 

 Context:
-${Object.keys(context).map(key => `    - ${key} : ${context[key]}`)
+${Object.keys(context).map(key => `    - ${key} : ${JSON.stringify(context[key]).replace(/\n/g, '
')}`) .join('\n')}
` @@ -100,4 +103,12 @@ ${Object.keys(context).map(key => ` - ${key} : ${context[key]}`) protected buildJSON(thrownError: Error): string { return JSON.stringify(api.error(thrownError)) } + + protected getSuggestion(): string { + if ( this.thrownError.message.startsWith('No such dependency is registered with this container: class SecurityContext') ) { + return 'It looks like this route relies on the security framework. Is the route you are accessing inside a middleware (e.g. SessionAuthMiddleware)?' + } + + return '' + } } diff --git a/src/http/response/RedirectResponseFactory.ts b/src/http/response/RedirectResponseFactory.ts index b4825c3..2f43f28 100644 --- a/src/http/response/RedirectResponseFactory.ts +++ b/src/http/response/RedirectResponseFactory.ts @@ -6,8 +6,8 @@ import {Request} from '../lifecycle/Request' * Helper function to create a new RedirectResponseFactory to the given destination. * @param destination */ -export function redirect(destination: string): RedirectResponseFactory { - return new RedirectResponseFactory(destination) +export function redirect(destination: string|URL): RedirectResponseFactory { + return new RedirectResponseFactory(destination instanceof URL ? destination.toString() : destination) } /** diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index e5fd4d6..3529798 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -72,12 +72,16 @@ export class Route route.preflight.prepend(def)) + .each(def => route.preflight.prepend( + request => request.make(def, request).apply(), + )) } for ( const group of this.compiledGroupStack ) { group.getPostflight() - .each(def => route.postflight.push(def)) + .each(def => route.postflight.push( + request => request.make(def, request).apply(), + )) } // Add the global pre- and post- middleware diff --git a/src/http/routing/RouteGroup.ts b/src/http/routing/RouteGroup.ts index 2da2ecf..01afc6f 100644 --- a/src/http/routing/RouteGroup.ts +++ b/src/http/routing/RouteGroup.ts @@ -1,16 +1,16 @@ import {Collection, ErrorWithContext} from '../../util' import {AppClass} from '../../lifecycle/AppClass' -import {ResolvedRouteHandler} from './Route' -import {Container} from '../../di' +import {Container, Instantiable} from '../../di' import {Logging} from '../../service/Logging' +import {Middleware} from './Middleware' /** * Class that defines a group of Routes in the application, with a prefix. */ export class RouteGroup extends AppClass { - protected preflight: Collection = new Collection() + protected preflight: Collection> = new Collection() - protected postflight: Collection = new Collection() + protected postflight: Collection> = new Collection() /** * The current set of nested groups. This is used when compiling route groups. @@ -87,22 +87,22 @@ export class RouteGroup extends AppClass { } /** Register the given middleware to be applied before all routes in this group. */ - pre(middleware: ResolvedRouteHandler): this { + pre(middleware: Instantiable): this { this.preflight.push(middleware) return this } /** Register the given middleware to be applied after all routes in this group. */ - post(middleware: ResolvedRouteHandler): this { + post(middleware: Instantiable): this { this.postflight.push(middleware) return this } - getPreflight(): Collection { + getPreflight(): Collection> { return this.preflight } - getPostflight(): Collection { + getPostflight(): Collection> { return this.postflight } } diff --git a/src/http/session/Session.ts b/src/http/session/Session.ts index a0385cf..56a9251 100644 --- a/src/http/session/Session.ts +++ b/src/http/session/Session.ts @@ -1,5 +1,5 @@ import {Injectable, Inject} from '../../di' -import {ErrorWithContext} from '../../util' +import {ErrorWithContext, Safe} from '../../util' import {Request} from '../lifecycle/Request' /** @@ -60,4 +60,9 @@ export abstract class Session { /** Remove a key from the session data. */ public abstract forget(key: string): void + + /** Load a key from the session as a Safe value. */ + public safe(key: string): Safe { + return new Safe(this.get(key)) + } } diff --git a/src/lifecycle/Application.ts b/src/lifecycle/Application.ts index d6c9294..c3e1580 100644 --- a/src/lifecycle/Application.ts +++ b/src/lifecycle/Application.ts @@ -3,7 +3,7 @@ import { ErrorWithContext, globalRegistry, infer, - isLoggingLevel, + isLoggingLevel, logIfDebugging, PathLike, StandardLogger, universalPath, @@ -237,9 +237,11 @@ export class Application extends Container { * @protected */ protected bootstrapEnvironment(): void { + logIfDebugging('extollo.env', `.env path: ${this.basePath.concat('.env').toLocal}`) dotenv.config({ path: this.basePath.concat('.env').toLocal, }) + logIfDebugging('extollo.env', process.env) } /** diff --git a/src/orm/model/Model.ts b/src/orm/model/Model.ts index 5b913dc..c60bf6e 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -1022,7 +1022,7 @@ export abstract class Model> extends LocalBus> * @param scope * @protected */ - protected scope(scope: Instantiable | ScopeClosure): this { + protected withScope(scope: Instantiable | ScopeClosure): this { if ( isInstantiable(scope) ) { if ( !this.hasScope(scope) ) { this.scopes.push({ diff --git a/src/resources/views/oauth2/authorize.pug b/src/resources/views/oauth2/authorize.pug index 47535e6..d3d603c 100644 --- a/src/resources/views/oauth2/authorize.pug +++ b/src/resources/views/oauth2/authorize.pug @@ -9,7 +9,12 @@ block content else p This will allow #{clientName} full access to your account. - //p After allowing this, you may not be prompted again. + div(style='display: flex; flex-direction: row; padding-top: 20px') + form(method='get' action=(hasRoute('home') ? named('home') : route('/'))) + button.button(type='submit') Deny - if buttonText && buttonUrl - a.button(href=buttonUrl) #{buttonText} + form(method='post' style='padding-left: 20px') + button.button(type='submit') Allow + + p + small(style='color: var(--color-accent-text)') After allowing, will redirect to: #{redirectDomain} diff --git a/src/service/Canonical.ts b/src/service/Canonical.ts index a4bf973..c8842fd 100644 --- a/src/service/Canonical.ts +++ b/src/service/Canonical.ts @@ -2,7 +2,7 @@ * Base type for a canonical definition. */ import {Canon} from './Canon' -import {universalPath, UniversalPath, ErrorWithContext} from '../util' +import {universalPath, UniversalPath, ErrorWithContext, Safe} from '../util' import {Logging} from './Logging' import {Inject} from '../di' import * as nodePath from 'path' @@ -192,6 +192,18 @@ export abstract class Canonical extends Unit { return this.loadedItems[key] } + /** Get a canonical item by key as a Safe value. */ + public safe(key: string): Safe { + return (new Safe(this.get(key))).onError((message, value) => { + throw new ErrorWithContext(`Invalid canonical value: ${message}`, { + canonicalKey: key, + canonicalItems: this.canonicalItems, + value, + message, + }) + }) + } + /** * Register a namespace resolver with the canonical unit. * diff --git a/src/util/cache/Cache.ts b/src/util/cache/Cache.ts index 8cbdc48..5d44913 100644 --- a/src/util/cache/Cache.ts +++ b/src/util/cache/Cache.ts @@ -1,4 +1,5 @@ import {Awaitable} from '../support/types' +import {Safe} from '../support/Safe' /** * Abstract interface class for a cached object. @@ -11,6 +12,14 @@ export abstract class Cache { */ public abstract fetch(key: string): Awaitable; + /** + * Fetch a value from the cache by its key as a Safe value. + * @param key + */ + public async safe(key: string): Promise { + return new Safe(await this.fetch(key)) + } + /** * Store the given value in the cache by key. * @param {string} key diff --git a/src/util/error/ErrorWithContext.ts b/src/util/error/ErrorWithContext.ts index 4085857..5aaf681 100644 --- a/src/util/error/ErrorWithContext.ts +++ b/src/util/error/ErrorWithContext.ts @@ -27,3 +27,25 @@ export class ErrorWithContext extends Error { } } } + +export function withErrorContext(closure: () => T, context: {[key: string]: any}): T { + try { + return closure() + } catch (e: unknown) { + if ( e instanceof ErrorWithContext ) { + e.context = { + ...e.context, + ...context, + } + throw e + } else if ( e instanceof Error ) { + const ewc = new ErrorWithContext(e.message, context) + ewc.stack = e.stack + ewc.name = e.name + ewc.originalError = e + throw ewc + } else { + throw e + } + } +} diff --git a/src/util/support/Safe.ts b/src/util/support/Safe.ts index b8ab848..f5db07f 100644 --- a/src/util/support/Safe.ts +++ b/src/util/support/Safe.ts @@ -1,5 +1,7 @@ import {Integer, isInteger} from './types' import {ErrorWithContext} from '../error/ErrorWithContext' +import {JSONState} from './Rehydratable' +import {isJSON} from './data' export class Safe { protected thrower: (message: string, value: unknown) => never @@ -22,10 +24,10 @@ export class Safe { present(): this { if ( !this.value && this.value !== 0 && this.value !== false ) { - return this + this.thrower('Missing value', this.value) } - this.thrower('Missing value', this.value) + return this } integer(): Integer { @@ -51,6 +53,23 @@ export class Safe { return String(this.value) } + json(): JSONState { + const str = this.string() + if ( !isJSON(str) ) { + this.thrower('Invalid JSON', str) + } + + return JSON.parse(str) + } + + or(other: unknown): Safe { + if ( !this.value && this.value !== 0 && this.value !== false ) { + return new Safe(other) + } + + return this + } + in(allowed: T[]): T { if ( allowed.includes(this.value as any) ) { return this.value as T