Error response enhancements, CoreID auth client backend
This commit is contained in:
parent
a039b1ff25
commit
8f08b94f74
@ -14,6 +14,7 @@
|
|||||||
"@types/busboy": "^0.2.3",
|
"@types/busboy": "^0.2.3",
|
||||||
"@types/cli-table": "^0.3.0",
|
"@types/cli-table": "^0.3.0",
|
||||||
"@types/ioredis": "^4.26.6",
|
"@types/ioredis": "^4.26.6",
|
||||||
|
"@types/jsonwebtoken": "^8.5.8",
|
||||||
"@types/mime-types": "^2.1.0",
|
"@types/mime-types": "^2.1.0",
|
||||||
"@types/mkdirp": "^1.0.1",
|
"@types/mkdirp": "^1.0.1",
|
||||||
"@types/negotiator": "^0.6.1",
|
"@types/negotiator": "^0.6.1",
|
||||||
@ -30,6 +31,7 @@
|
|||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"ioredis": "^4.27.6",
|
"ioredis": "^4.27.6",
|
||||||
|
"jsonwebtoken": "^8.5.1",
|
||||||
"mime-types": "^2.1.31",
|
"mime-types": "^2.1.31",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"negotiator": "^0.6.2",
|
"negotiator": "^0.6.2",
|
||||||
|
@ -8,6 +8,7 @@ specifiers:
|
|||||||
'@types/chai': ^4.2.22
|
'@types/chai': ^4.2.22
|
||||||
'@types/cli-table': ^0.3.0
|
'@types/cli-table': ^0.3.0
|
||||||
'@types/ioredis': ^4.26.6
|
'@types/ioredis': ^4.26.6
|
||||||
|
'@types/jsonwebtoken': ^8.5.8
|
||||||
'@types/mime-types': ^2.1.0
|
'@types/mime-types': ^2.1.0
|
||||||
'@types/mkdirp': ^1.0.1
|
'@types/mkdirp': ^1.0.1
|
||||||
'@types/mocha': ^9.0.0
|
'@types/mocha': ^9.0.0
|
||||||
@ -30,6 +31,7 @@ specifiers:
|
|||||||
dotenv: ^8.2.0
|
dotenv: ^8.2.0
|
||||||
eslint: ^7.27.0
|
eslint: ^7.27.0
|
||||||
ioredis: ^4.27.6
|
ioredis: ^4.27.6
|
||||||
|
jsonwebtoken: ^8.5.1
|
||||||
mime-types: ^2.1.31
|
mime-types: ^2.1.31
|
||||||
mkdirp: ^1.0.4
|
mkdirp: ^1.0.4
|
||||||
mocha: ^9.1.3
|
mocha: ^9.1.3
|
||||||
@ -57,6 +59,7 @@ dependencies:
|
|||||||
'@types/busboy': 0.2.3
|
'@types/busboy': 0.2.3
|
||||||
'@types/cli-table': 0.3.0
|
'@types/cli-table': 0.3.0
|
||||||
'@types/ioredis': 4.26.6
|
'@types/ioredis': 4.26.6
|
||||||
|
'@types/jsonwebtoken': 8.5.8
|
||||||
'@types/mime-types': 2.1.0
|
'@types/mime-types': 2.1.0
|
||||||
'@types/mkdirp': 1.0.1
|
'@types/mkdirp': 1.0.1
|
||||||
'@types/negotiator': 0.6.1
|
'@types/negotiator': 0.6.1
|
||||||
@ -73,6 +76,7 @@ dependencies:
|
|||||||
colors: 1.4.0
|
colors: 1.4.0
|
||||||
dotenv: 8.2.0
|
dotenv: 8.2.0
|
||||||
ioredis: 4.27.6
|
ioredis: 4.27.6
|
||||||
|
jsonwebtoken: 8.5.1
|
||||||
mime-types: 2.1.31
|
mime-types: 2.1.31
|
||||||
mkdirp: 1.0.4
|
mkdirp: 1.0.4
|
||||||
negotiator: 0.6.2
|
negotiator: 0.6.2
|
||||||
@ -338,6 +342,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==}
|
resolution: {integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==}
|
||||||
dev: true
|
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:
|
/@types/mime-types/2.1.0:
|
||||||
resolution: {integrity: sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=}
|
resolution: {integrity: sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=}
|
||||||
dev: false
|
dev: false
|
||||||
@ -782,6 +792,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==}
|
resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/buffer-equal-constant-time/1.0.1:
|
||||||
|
resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/buffer-from/1.1.1:
|
/buffer-from/1.1.1:
|
||||||
resolution: {integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==}
|
resolution: {integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -1132,6 +1146,12 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: false
|
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:
|
/emoji-regex/8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
@ -1770,6 +1790,22 @@ packages:
|
|||||||
graceful-fs: 4.2.6
|
graceful-fs: 4.2.6
|
||||||
dev: false
|
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:
|
/jstransformer/1.0.0:
|
||||||
resolution: {integrity: sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=}
|
resolution: {integrity: sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1781,6 +1817,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==}
|
resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==}
|
||||||
dev: true
|
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:
|
/levn/0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -1812,10 +1863,38 @@ packages:
|
|||||||
resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=}
|
resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=}
|
||||||
dev: true
|
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:
|
/lodash.merge/4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lodash.once/4.1.1:
|
||||||
|
resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.truncate/4.4.2:
|
/lodash.truncate/4.4.2:
|
||||||
resolution: {integrity: sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=}
|
resolution: {integrity: sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=}
|
||||||
dev: true
|
dev: true
|
||||||
@ -1959,7 +2038,6 @@ packages:
|
|||||||
|
|
||||||
/ms/2.1.3:
|
/ms/2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/mute-stream/0.0.8:
|
/mute-stream/0.0.8:
|
||||||
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
|
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
|
||||||
@ -2508,6 +2586,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/semver/5.7.1:
|
||||||
|
resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
|
|
||||||
/semver/6.3.0:
|
/semver/6.3.0:
|
||||||
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
|
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
@ -61,7 +61,7 @@ export class Authentication extends Unit {
|
|||||||
|
|
||||||
Route.group(`/auth/${name}`, () => {
|
Route.group(`/auth/${name}`, () => {
|
||||||
this.providers[name].routes()
|
this.providers[name].routes()
|
||||||
}).pre(request => request.make<Middleware>(middleware, request).apply())
|
}).pre(middleware)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {Awaitable, Maybe} from '../../util'
|
import {Awaitable, HTTPStatus, Maybe} from '../../util'
|
||||||
import {Authenticatable, AuthenticatableRepository} from '../types'
|
import {Authenticatable, AuthenticatableRepository} from '../types'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
|
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
|
||||||
import {UserFlushedEvent} from '../event/UserFlushedEvent'
|
import {UserFlushedEvent} from '../event/UserFlushedEvent'
|
||||||
import {Bus} from '../../support/bus'
|
import {Bus} from '../../support/bus'
|
||||||
|
import {HTTPError} from '../../http/HTTPError'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base-class for a context that authenticates users and manages security.
|
* Base-class for a context that authenticates users and manages security.
|
||||||
@ -95,6 +96,20 @@ export abstract class SecurityContext {
|
|||||||
return this.authenticatedUser
|
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.
|
* Returns true if there is a currently authenticated user.
|
||||||
*/
|
*/
|
||||||
|
@ -30,8 +30,13 @@ export * from './repository/orm/ORMUserRepository'
|
|||||||
export * from './config'
|
export * from './config'
|
||||||
|
|
||||||
export * from './server/types'
|
export * from './server/types'
|
||||||
|
export * from './server/models/OAuth2TokenModel'
|
||||||
export * from './server/repositories/ConfigClientRepository'
|
export * from './server/repositories/ConfigClientRepository'
|
||||||
export * from './server/repositories/ConfigScopeRepository'
|
export * from './server/repositories/ConfigScopeRepository'
|
||||||
export * from './server/repositories/ClientRepositoryFactory'
|
export * from './server/repositories/ClientRepositoryFactory'
|
||||||
export * from './server/repositories/ScopeRepositoryFactory'
|
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'
|
export * from './server/OAuth2Server'
|
||||||
|
@ -26,8 +26,8 @@ export class AuthRequiredMiddleware extends Middleware {
|
|||||||
if ( !this.security.hasUser() ) {
|
if ( !this.security.hasUser() ) {
|
||||||
this.session.set('@extollo:auth.intention', this.request.url)
|
this.session.set('@extollo:auth.intention', this.request.url)
|
||||||
|
|
||||||
if ( this.routing.hasNamedRoute('@auth.login') ) {
|
if ( this.routing.hasNamedRoute('@auth:login') ) {
|
||||||
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
return redirect(this.routing.getNamedPath('@auth:login').toRemote)
|
||||||
} else {
|
} else {
|
||||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {Inject, Injectable} from '../../di'
|
|||||||
import {SecurityContext} from '../context/SecurityContext'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
import {RequestLocalStorage} from '../../http/RequestLocalStorage'
|
import {RequestLocalStorage} from '../../http/RequestLocalStorage'
|
||||||
|
import {Session} from '../../http/session/Session'
|
||||||
|
|
||||||
export interface LoginProviderConfig {
|
export interface LoginProviderConfig {
|
||||||
default: boolean,
|
default: boolean,
|
||||||
@ -61,6 +62,13 @@ export abstract class LoginProvider<TConfig extends LoginProviderConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected redirectToIntendedRoute(): ResponseObject {
|
protected redirectToIntendedRoute(): ResponseObject {
|
||||||
return redirect('/') // FIXME
|
const intent = this.request
|
||||||
|
.get()
|
||||||
|
.make<Session>(Session)
|
||||||
|
.safe('@extollo:auth.intention')
|
||||||
|
.or('/')
|
||||||
|
.string()
|
||||||
|
|
||||||
|
return redirect(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -90,6 +90,7 @@ export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Update values on the Authenticatable from user data. */
|
/** Update values on the Authenticatable from user data. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||||
protected updateUser(user: any, data: any): void {
|
protected updateUser(user: any, data: any): void {
|
||||||
user.firstName = data.first_name
|
user.firstName = data.first_name
|
||||||
user.lastName = data.last_name
|
user.lastName = data.last_name
|
||||||
|
@ -3,22 +3,98 @@ import {Injectable} from '../../di'
|
|||||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||||
import {Request} from '../../http/lifecycle/Request'
|
import {Request} from '../../http/lifecycle/Request'
|
||||||
import {Session} from '../../http/session/Session'
|
import {Session} from '../../http/session/Session'
|
||||||
import {OAuth2Client, ClientRepository, OAuth2Scope, ScopeRepository} from './types'
|
import {
|
||||||
|
ClientRepository,
|
||||||
|
OAuth2Client,
|
||||||
|
OAuth2FlowType,
|
||||||
|
OAuth2Scope,
|
||||||
|
RedemptionCodeRepository,
|
||||||
|
ScopeRepository,
|
||||||
|
} from './types'
|
||||||
import {HTTPError} from '../../http/HTTPError'
|
import {HTTPError} from '../../http/HTTPError'
|
||||||
import {HTTPStatus, Maybe} from '../../util'
|
import {HTTPStatus, Maybe} from '../../util'
|
||||||
import {view} from '../../http/response/ViewResponseFactory'
|
import {view} from '../../http/response/ViewResponseFactory'
|
||||||
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuth2Server extends Controller {
|
export class OAuth2Server extends Controller {
|
||||||
public static routes(): void {
|
public static routes(): void {
|
||||||
Route.get('/oauth2/authorize')
|
Route.get('/oauth2/authorize')
|
||||||
|
.alias('@oauth2:authorize')
|
||||||
|
.pre(AuthRequiredMiddleware)
|
||||||
.passingRequest()
|
.passingRequest()
|
||||||
.calls<OAuth2Server>(OAuth2Server, x => x.promptForAuthorization.bind(x))
|
.calls<OAuth2Server>(OAuth2Server, x => x.promptForAuthorization)
|
||||||
|
|
||||||
|
Route.post('/oauth2/authorize')
|
||||||
|
.alias('@oauth2:authorize:submit')
|
||||||
|
.pre(AuthRequiredMiddleware)
|
||||||
|
.passingRequest()
|
||||||
|
.calls<OAuth2Server>(OAuth2Server, x => x.authorizeAndRedirect)
|
||||||
|
|
||||||
|
Route.post('/oauth2/redeem')
|
||||||
|
.alias('@oauth2:authorize:redeem')
|
||||||
|
.passingRequest()
|
||||||
|
.calls<OAuth2Server>(OAuth2Server, x => x.redeemToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
async redeemToken(request: Request): Promise<ResponseObject> {
|
||||||
|
const authParts = String(request.getHeader('Authorization')).split(':')
|
||||||
|
if ( authParts.length !== 2 ) {
|
||||||
|
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientRepo = <ClientRepository> 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 = <RedemptionCodeRepository> 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<ResponseObject> {
|
||||||
|
// Look up the client in the client repo
|
||||||
|
const session = <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<ResponseObject> {
|
||||||
|
const session = <Session> request.make(Session)
|
||||||
|
const security = <SecurityContext> request.make(SecurityContext)
|
||||||
|
const codeRepository = <RedemptionCodeRepository> 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<ResponseObject> {
|
async promptForAuthorization(request: Request): Promise<ResponseObject> {
|
||||||
// Look up the client in the client repo
|
// 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
|
// Make sure the requested flow type is valid for this client
|
||||||
const session = <Session> request.make(Session)
|
const session = <Session> request.make(Session)
|
||||||
@ -43,12 +119,12 @@ export class OAuth2Server extends Controller {
|
|||||||
return view('@extollo:oauth2:authorize', {
|
return view('@extollo:oauth2:authorize', {
|
||||||
clientName: client.display,
|
clientName: client.display,
|
||||||
scopeDescription: scope?.description,
|
scopeDescription: scope?.description,
|
||||||
|
redirectDomain: (new URL(redirectUri)).host,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getClient(request: Request): Promise<OAuth2Client> {
|
protected async getClient(request: Request, clientId: string): Promise<OAuth2Client> {
|
||||||
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
||||||
const clientId = request.safe('client_id').string()
|
|
||||||
const client = await clientRepo.find(clientId)
|
const client = await clientRepo.find(clientId)
|
||||||
if ( !client ) {
|
if ( !client ) {
|
||||||
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', {
|
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', {
|
||||||
|
30
src/auth/server/models/OAuth2TokenModel.ts
Normal file
30
src/auth/server/models/OAuth2TokenModel.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {Field, FieldType, Model} from '../../../orm'
|
||||||
|
import {OAuth2Token} from '../types'
|
||||||
|
|
||||||
|
export class OAuth2TokenModel extends Model<OAuth2TokenModel> 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
|
||||||
|
}
|
@ -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<Maybe<OAuth2RedemptionCode>> {
|
||||||
|
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<OAuth2RedemptionCode> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
88
src/auth/server/repositories/ORMTokenRepository.ts
Normal file
88
src/auth/server/repositories/ORMTokenRepository.ts
Normal file
@ -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<Maybe<OAuth2Token>> {
|
||||||
|
const idNum = parseInt(id, 10)
|
||||||
|
if ( !isNaN(idNum) ) {
|
||||||
|
return OAuth2TokenModel.query<OAuth2TokenModel>()
|
||||||
|
.whereKey(idNum)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
|
||||||
|
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<OAuth2TokenString> {
|
||||||
|
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<string>((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<Maybe<OAuth2Token>> {
|
||||||
|
const secret = this.config.safe('oauth2.secret').string()
|
||||||
|
const decoded = await new Promise<any>((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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<RedemptionCodeRepository> {
|
||||||
|
protected get config(): Config {
|
||||||
|
return Container.getContainer().make<Config>(Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(): RedemptionCodeRepository {
|
||||||
|
return new (this.getRedemptionCodeRepositoryClass())()
|
||||||
|
}
|
||||||
|
|
||||||
|
match(something: unknown): boolean {
|
||||||
|
return something === RedemptionCodeRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getRedemptionCodeRepositoryClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
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<RedemptionCodeRepository>
|
||||||
|
*/
|
||||||
|
protected getRedemptionCodeRepositoryClass(): Instantiable<RedemptionCodeRepository> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
74
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal file
74
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal file
@ -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<TokenRepository> {
|
||||||
|
protected get config(): Config {
|
||||||
|
return Container.getContainer().make<Config>(Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(): TokenRepository {
|
||||||
|
return new (this.getTokenRepositoryClass())()
|
||||||
|
}
|
||||||
|
|
||||||
|
match(something: unknown): boolean {
|
||||||
|
return something === TokenRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getTokenRepositoryClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
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<TokenRepository>
|
||||||
|
*/
|
||||||
|
protected getTokenRepositoryClass(): Instantiable<TokenRepository> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
export enum OAuth2FlowType {
|
||||||
code = 'code',
|
code = 'code',
|
||||||
@ -81,3 +82,93 @@ export abstract class ScopeRepository {
|
|||||||
|
|
||||||
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
|
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Maybe<OAuth2Token>>
|
||||||
|
|
||||||
|
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2Token>
|
||||||
|
|
||||||
|
abstract decode(token: OAuth2TokenString): Awaitable<Maybe<OAuth2Token>>
|
||||||
|
|
||||||
|
abstract encode(token: OAuth2Token): Awaitable<OAuth2TokenString>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Maybe<OAuth2RedemptionCode>>
|
||||||
|
|
||||||
|
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2RedemptionCode>
|
||||||
|
}
|
||||||
|
@ -451,14 +451,19 @@ export class Container {
|
|||||||
|
|
||||||
this.checkForMakeCycles()
|
this.checkForMakeCycles()
|
||||||
|
|
||||||
if ( this.hasKey(target) ) {
|
try {
|
||||||
const realized = this.resolveAndCreate(target, ...parameters)
|
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()
|
Container.makeStack.pop()
|
||||||
return realized
|
throw e
|
||||||
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
|
|
||||||
const realized = this.produceFactory(new Factory(target), parameters)
|
|
||||||
Container.makeStack.pop()
|
|
||||||
return realized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Container.makeStack.pop()
|
Container.makeStack.pop()
|
||||||
|
@ -2,7 +2,7 @@ import {HTTPKernel} from '../HTTPKernel'
|
|||||||
import {Request} from '../../lifecycle/Request'
|
import {Request} from '../../lifecycle/Request'
|
||||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||||
import {http} from '../../response/HTTPErrorResponseFactory'
|
import {http} from '../../response/HTTPErrorResponseFactory'
|
||||||
import {HTTPStatus} from '../../../util'
|
import {HTTPStatus, withErrorContext} from '../../../util'
|
||||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,11 +23,15 @@ export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRoute
|
|||||||
throw new Error('Attempted to call route handler without resolved parameters.')
|
throw new Error('Attempted to call route handler without resolved parameters.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await route.handler
|
await withErrorContext(async () => {
|
||||||
.tap(handler => handler(...params))
|
const result = await route.handler
|
||||||
.apply(request)
|
.tap(handler => handler(...params))
|
||||||
|
.apply(request)
|
||||||
|
|
||||||
await this.applyResponseObject(result, request)
|
await this.applyResponseObject(result, request)
|
||||||
|
}, {
|
||||||
|
route,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await http(HTTPStatus.NOT_FOUND).write(request)
|
await http(HTTPStatus.NOT_FOUND).write(request)
|
||||||
request.response.blockingWriteback(true)
|
request.response.blockingWriteback(true)
|
||||||
|
@ -4,7 +4,7 @@ import {Request} from '../../lifecycle/Request'
|
|||||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||||
import {ResponseObject} from '../../routing/Route'
|
import {ResponseObject} from '../../routing/Route'
|
||||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
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.
|
* HTTP Kernel module that executes the preflight handlers for the route.
|
||||||
@ -22,11 +22,13 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou
|
|||||||
const preflight = route.preflight
|
const preflight = route.preflight
|
||||||
|
|
||||||
for ( const handler of preflight ) {
|
for ( const handler of preflight ) {
|
||||||
const result: ResponseObject = await handler(request)
|
await withErrorContext(async () => {
|
||||||
if ( typeof result !== 'undefined' ) {
|
const result: ResponseObject = await handler(request)
|
||||||
await this.applyResponseObject(result, request)
|
if ( typeof result !== 'undefined' ) {
|
||||||
request.response.blockingWriteback(true)
|
await this.applyResponseObject(result, request)
|
||||||
}
|
request.response.blockingWriteback(true)
|
||||||
|
}
|
||||||
|
}, { handler })
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = route.parameters
|
const parameters = route.parameters
|
||||||
|
@ -195,6 +195,10 @@ export class Response {
|
|||||||
*/
|
*/
|
||||||
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||||
return new Promise<void>((res, rej) => {
|
return new Promise<void>((res, rej) => {
|
||||||
|
if ( this.responseEnded ) {
|
||||||
|
throw new ErrorWithContext('Tried to write to Response after lifecycle ended.')
|
||||||
|
}
|
||||||
|
|
||||||
if ( !this.sentHeaders ) {
|
if ( !this.sentHeaders ) {
|
||||||
this.sendHeaders()
|
this.sendHeaders()
|
||||||
}
|
}
|
||||||
|
@ -72,9 +72,12 @@ export class ErrorResponseFactory extends ResponseFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const suggestion = this.getSuggestion()
|
||||||
|
|
||||||
let str = `
|
let str = `
|
||||||
<b>Sorry, an unexpected error occurred while processing your request.</b>
|
<b>Sorry, an unexpected error occurred while processing your request.</b>
|
||||||
<br>
|
<br>
|
||||||
|
${suggestion ? '<br><b>Suggestion:</b> ' + suggestion + '<br>' : ''}
|
||||||
<pre><code>
|
<pre><code>
|
||||||
Name: ${thrownError.name}
|
Name: ${thrownError.name}
|
||||||
Message: ${thrownError.message}
|
Message: ${thrownError.message}
|
||||||
@ -88,7 +91,7 @@ Stack trace:
|
|||||||
str += `
|
str += `
|
||||||
<pre><code>
|
<pre><code>
|
||||||
Context:
|
Context:
|
||||||
${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)
|
${Object.keys(context).map(key => ` - ${key} : ${JSON.stringify(context[key]).replace(/\n/g, '<br>')}`)
|
||||||
.join('\n')}
|
.join('\n')}
|
||||||
</code></pre>
|
</code></pre>
|
||||||
`
|
`
|
||||||
@ -100,4 +103,12 @@ ${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)
|
|||||||
protected buildJSON(thrownError: Error): string {
|
protected buildJSON(thrownError: Error): string {
|
||||||
return JSON.stringify(api.error(thrownError))
|
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 ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,8 @@ import {Request} from '../lifecycle/Request'
|
|||||||
* Helper function to create a new RedirectResponseFactory to the given destination.
|
* Helper function to create a new RedirectResponseFactory to the given destination.
|
||||||
* @param destination
|
* @param destination
|
||||||
*/
|
*/
|
||||||
export function redirect(destination: string): RedirectResponseFactory {
|
export function redirect(destination: string|URL): RedirectResponseFactory {
|
||||||
return new RedirectResponseFactory(destination)
|
return new RedirectResponseFactory(destination instanceof URL ? destination.toString() : destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -72,12 +72,16 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
|||||||
for ( const group of stack ) {
|
for ( const group of stack ) {
|
||||||
route.prepend(group.prefix)
|
route.prepend(group.prefix)
|
||||||
group.getPreflight()
|
group.getPreflight()
|
||||||
.each(def => route.preflight.prepend(def))
|
.each(def => route.preflight.prepend(
|
||||||
|
request => request.make<Middleware>(def, request).apply(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
for ( const group of this.compiledGroupStack ) {
|
for ( const group of this.compiledGroupStack ) {
|
||||||
group.getPostflight()
|
group.getPostflight()
|
||||||
.each(def => route.postflight.push(def))
|
.each(def => route.postflight.push(
|
||||||
|
request => request.make<Middleware>(def, request).apply(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the global pre- and post- middleware
|
// Add the global pre- and post- middleware
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import {Collection, ErrorWithContext} from '../../util'
|
import {Collection, ErrorWithContext} from '../../util'
|
||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
import {ResolvedRouteHandler} from './Route'
|
import {Container, Instantiable} from '../../di'
|
||||||
import {Container} from '../../di'
|
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Middleware} from './Middleware'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class that defines a group of Routes in the application, with a prefix.
|
* Class that defines a group of Routes in the application, with a prefix.
|
||||||
*/
|
*/
|
||||||
export class RouteGroup extends AppClass {
|
export class RouteGroup extends AppClass {
|
||||||
protected preflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
|
protected preflight: Collection<Instantiable<Middleware>> = new Collection()
|
||||||
|
|
||||||
protected postflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
|
protected postflight: Collection<Instantiable<Middleware>> = new Collection()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current set of nested groups. This is used when compiling route groups.
|
* 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. */
|
/** Register the given middleware to be applied before all routes in this group. */
|
||||||
pre(middleware: ResolvedRouteHandler): this {
|
pre(middleware: Instantiable<Middleware>): this {
|
||||||
this.preflight.push(middleware)
|
this.preflight.push(middleware)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register the given middleware to be applied after all routes in this group. */
|
/** Register the given middleware to be applied after all routes in this group. */
|
||||||
post(middleware: ResolvedRouteHandler): this {
|
post(middleware: Instantiable<Middleware>): this {
|
||||||
this.postflight.push(middleware)
|
this.postflight.push(middleware)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreflight(): Collection<ResolvedRouteHandler> {
|
getPreflight(): Collection<Instantiable<Middleware>> {
|
||||||
return this.preflight
|
return this.preflight
|
||||||
}
|
}
|
||||||
|
|
||||||
getPostflight(): Collection<ResolvedRouteHandler> {
|
getPostflight(): Collection<Instantiable<Middleware>> {
|
||||||
return this.postflight
|
return this.postflight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {Injectable, Inject} from '../../di'
|
import {Injectable, Inject} from '../../di'
|
||||||
import {ErrorWithContext} from '../../util'
|
import {ErrorWithContext, Safe} from '../../util'
|
||||||
import {Request} from '../lifecycle/Request'
|
import {Request} from '../lifecycle/Request'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,4 +60,9 @@ export abstract class Session {
|
|||||||
|
|
||||||
/** Remove a key from the session data. */
|
/** Remove a key from the session data. */
|
||||||
public abstract forget(key: string): void
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import {
|
|||||||
ErrorWithContext,
|
ErrorWithContext,
|
||||||
globalRegistry,
|
globalRegistry,
|
||||||
infer,
|
infer,
|
||||||
isLoggingLevel,
|
isLoggingLevel, logIfDebugging,
|
||||||
PathLike,
|
PathLike,
|
||||||
StandardLogger,
|
StandardLogger,
|
||||||
universalPath,
|
universalPath,
|
||||||
@ -237,9 +237,11 @@ export class Application extends Container {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected bootstrapEnvironment(): void {
|
protected bootstrapEnvironment(): void {
|
||||||
|
logIfDebugging('extollo.env', `.env path: ${this.basePath.concat('.env').toLocal}`)
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: this.basePath.concat('.env').toLocal,
|
path: this.basePath.concat('.env').toLocal,
|
||||||
})
|
})
|
||||||
|
logIfDebugging('extollo.env', process.env)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1022,7 +1022,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
|
|||||||
* @param scope
|
* @param scope
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected scope(scope: Instantiable<Scope> | ScopeClosure): this {
|
protected withScope(scope: Instantiable<Scope> | ScopeClosure): this {
|
||||||
if ( isInstantiable(scope) ) {
|
if ( isInstantiable(scope) ) {
|
||||||
if ( !this.hasScope(scope) ) {
|
if ( !this.hasScope(scope) ) {
|
||||||
this.scopes.push({
|
this.scopes.push({
|
||||||
|
@ -9,7 +9,12 @@ block content
|
|||||||
else
|
else
|
||||||
p This will allow #{clientName} full access to your account.
|
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
|
form(method='post' style='padding-left: 20px')
|
||||||
a.button(href=buttonUrl) #{buttonText}
|
button.button(type='submit') Allow
|
||||||
|
|
||||||
|
p
|
||||||
|
small(style='color: var(--color-accent-text)') After allowing, will redirect to: #{redirectDomain}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
* Base type for a canonical definition.
|
* Base type for a canonical definition.
|
||||||
*/
|
*/
|
||||||
import {Canon} from './Canon'
|
import {Canon} from './Canon'
|
||||||
import {universalPath, UniversalPath, ErrorWithContext} from '../util'
|
import {universalPath, UniversalPath, ErrorWithContext, Safe} from '../util'
|
||||||
import {Logging} from './Logging'
|
import {Logging} from './Logging'
|
||||||
import {Inject} from '../di'
|
import {Inject} from '../di'
|
||||||
import * as nodePath from 'path'
|
import * as nodePath from 'path'
|
||||||
@ -192,6 +192,18 @@ export abstract class Canonical<T> extends Unit {
|
|||||||
return this.loadedItems[key]
|
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.
|
* Register a namespace resolver with the canonical unit.
|
||||||
*
|
*
|
||||||
|
9
src/util/cache/Cache.ts
vendored
9
src/util/cache/Cache.ts
vendored
@ -1,4 +1,5 @@
|
|||||||
import {Awaitable} from '../support/types'
|
import {Awaitable} from '../support/types'
|
||||||
|
import {Safe} from '../support/Safe'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract interface class for a cached object.
|
* Abstract interface class for a cached object.
|
||||||
@ -11,6 +12,14 @@ export abstract class Cache {
|
|||||||
*/
|
*/
|
||||||
public abstract fetch(key: string): Awaitable<string|undefined>;
|
public abstract fetch(key: string): Awaitable<string|undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a value from the cache by its key as a Safe value.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
public async safe(key: string): Promise<Safe> {
|
||||||
|
return new Safe(await this.fetch(key))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store the given value in the cache by key.
|
* Store the given value in the cache by key.
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
|
@ -27,3 +27,25 @@ export class ErrorWithContext extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withErrorContext<T>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import {Integer, isInteger} from './types'
|
import {Integer, isInteger} from './types'
|
||||||
import {ErrorWithContext} from '../error/ErrorWithContext'
|
import {ErrorWithContext} from '../error/ErrorWithContext'
|
||||||
|
import {JSONState} from './Rehydratable'
|
||||||
|
import {isJSON} from './data'
|
||||||
|
|
||||||
export class Safe {
|
export class Safe {
|
||||||
protected thrower: (message: string, value: unknown) => never
|
protected thrower: (message: string, value: unknown) => never
|
||||||
@ -22,10 +24,10 @@ export class Safe {
|
|||||||
|
|
||||||
present(): this {
|
present(): this {
|
||||||
if ( !this.value && this.value !== 0 && this.value !== false ) {
|
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 {
|
integer(): Integer {
|
||||||
@ -51,6 +53,23 @@ export class Safe {
|
|||||||
return String(this.value)
|
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<T>(allowed: T[]): T {
|
in<T>(allowed: T[]): T {
|
||||||
if ( allowed.includes(this.value as any) ) {
|
if ( allowed.includes(this.value as any) ) {
|
||||||
return this.value as T
|
return this.value as T
|
||||||
|
Loading…
Reference in New Issue
Block a user