Add Safe value API and start OAuth2Server
This commit is contained in:
parent
70d67c2730
commit
a039b1ff25
@ -28,3 +28,10 @@ export * from './repository/orm/ORMUser'
|
||||
export * from './repository/orm/ORMUserRepository'
|
||||
|
||||
export * from './config'
|
||||
|
||||
export * from './server/types'
|
||||
export * from './server/repositories/ConfigClientRepository'
|
||||
export * from './server/repositories/ConfigScopeRepository'
|
||||
export * from './server/repositories/ClientRepositoryFactory'
|
||||
export * from './server/repositories/ScopeRepositoryFactory'
|
||||
export * from './server/OAuth2Server'
|
||||
|
82
src/auth/server/OAuth2Server.ts
Normal file
82
src/auth/server/OAuth2Server.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {OAuth2Client, ClientRepository, OAuth2Scope, ScopeRepository} from './types'
|
||||
import {HTTPError} from '../../http/HTTPError'
|
||||
import {HTTPStatus, Maybe} from '../../util'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Server extends Controller {
|
||||
public static routes(): void {
|
||||
Route.get('/oauth2/authorize')
|
||||
.passingRequest()
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.promptForAuthorization.bind(x))
|
||||
}
|
||||
|
||||
async promptForAuthorization(request: Request): Promise<ResponseObject> {
|
||||
// Look up the client in the client repo
|
||||
const client = await this.getClient(request)
|
||||
|
||||
// Make sure the requested flow type is valid for this client
|
||||
const session = <Session> request.make(Session)
|
||||
const flowType = request.safe('response_type').in(client.allowedFlows)
|
||||
const redirectUri = request.safe('redirect_uri').in(client.allowedRedirectUris)
|
||||
session.set('oauth2.authorize.clientId', client.id)
|
||||
session.set('oauth2.authorize.flow', flowType)
|
||||
session.set('oauth2.authorize.redirectUri', redirectUri)
|
||||
|
||||
// Set the state if necessary
|
||||
const state = request.input('state') || ''
|
||||
if ( state ) {
|
||||
session.set('oauth2.authorize.state', String(state))
|
||||
} else {
|
||||
session.forget('oauth2.authorize.state')
|
||||
}
|
||||
|
||||
// If the request specified a scope, validate it and set it in the session
|
||||
const scope = await this.getScope(request, client)
|
||||
|
||||
// Show a view prompting the user to approve the access
|
||||
return view('@extollo:oauth2:authorize', {
|
||||
clientName: client.display,
|
||||
scopeDescription: scope?.description,
|
||||
})
|
||||
}
|
||||
|
||||
protected async getClient(request: Request): Promise<OAuth2Client> {
|
||||
const clientRepo = <ClientRepository> 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', {
|
||||
clientId,
|
||||
})
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
protected async getScope(request: Request, client: OAuth2Client): Promise<Maybe<OAuth2Scope>> {
|
||||
const session = <Session> request.make(Session)
|
||||
const scopeName = String(request.input('scope') || '')
|
||||
let scope: Maybe<OAuth2Scope> = undefined
|
||||
if ( scopeName ) {
|
||||
const scopeRepo = <ScopeRepository> request.make(ScopeRepository)
|
||||
scope = await scopeRepo.findByName(scopeName)
|
||||
if ( !scope || !client.allowedScopeIds.includes(scope.id) ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid scope', {
|
||||
scopeName,
|
||||
})
|
||||
}
|
||||
|
||||
session.set('oauth2.authorize.scope', scope.id)
|
||||
} else {
|
||||
session.forget('oauth2.authorize.state')
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
}
|
74
src/auth/server/repositories/ClientRepositoryFactory.ts
Normal file
74
src/auth/server/repositories/ClientRepositoryFactory.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 {ClientRepository} from '../types'
|
||||
import {ConfigClientRepository} from './ConfigClientRepository'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ClientRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class ClientRepositoryFactory extends AbstractFactory<ClientRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
produce(): ClientRepository {
|
||||
return new (this.getClientRepositoryClass())()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === ClientRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getClientRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getClientRepositoryClass()
|
||||
|
||||
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<ClientRepository>
|
||||
*/
|
||||
protected getClientRepositoryClass(): Instantiable<ClientRepository> {
|
||||
const ClientRepositoryClass = this.config.get('oauth2.repository.client', ConfigClientRepository)
|
||||
|
||||
if ( !isInstantiable(ClientRepositoryClass) || !(ClientRepositoryClass.prototype instanceof ClientRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ClientRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: ClientRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return ClientRepositoryClass
|
||||
}
|
||||
}
|
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal file
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import {ClientRepository, OAuth2Client, isOAuth2Client} from '../types'
|
||||
import {Awaitable, ErrorWithContext, Maybe} from '../../../util'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
|
||||
@Injectable()
|
||||
export class ConfigClientRepository extends ClientRepository {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
find(id: string): Awaitable<Maybe<OAuth2Client>> {
|
||||
const client = this.config.get(`oauth2.clients.${id}`)
|
||||
if ( !isOAuth2Client(client) ) {
|
||||
throw new ErrorWithContext('Invalid OAuth2 client configuration', {
|
||||
id,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
}
|
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal file
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {isOAuth2Scope, OAuth2Scope, ScopeRepository} from '../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {Awaitable, Maybe} from '../../../util'
|
||||
|
||||
@Injectable()
|
||||
export class ConfigScopeRepository extends ScopeRepository {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
find(id: string): Awaitable<Maybe<OAuth2Scope>> {
|
||||
const scope = this.config.get(`oauth2.scopes.${id}`)
|
||||
if ( isOAuth2Scope(scope) ) {
|
||||
return scope
|
||||
}
|
||||
}
|
||||
|
||||
findByName(name: string): Awaitable<Maybe<OAuth2Scope>> {
|
||||
return this.find(name)
|
||||
}
|
||||
}
|
74
src/auth/server/repositories/ScopeRepositoryFactory.ts
Normal file
74
src/auth/server/repositories/ScopeRepositoryFactory.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 {ScopeRepository} from '../types'
|
||||
import {ConfigScopeRepository} from './ConfigScopeRepository'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ScopeRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class ScopeRepositoryFactory extends AbstractFactory<ScopeRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
produce(): ScopeRepository {
|
||||
return new (this.getScopeRepositoryClass())()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === ScopeRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getScopeRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getScopeRepositoryClass()
|
||||
|
||||
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 scope repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<ScopeRepository>
|
||||
*/
|
||||
protected getScopeRepositoryClass(): Instantiable<ScopeRepository> {
|
||||
const ScopeRepositoryClass = this.config.get('oauth2.repository.scope', ConfigScopeRepository)
|
||||
|
||||
if ( !isInstantiable(ScopeRepositoryClass) || !(ScopeRepositoryClass.prototype instanceof ScopeRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ScopeRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: ScopeRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return ScopeRepositoryClass
|
||||
}
|
||||
}
|
83
src/auth/server/types.ts
Normal file
83
src/auth/server/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import {Awaitable, hasOwnProperty, Maybe} from '../../util'
|
||||
|
||||
export enum OAuth2FlowType {
|
||||
code = 'code',
|
||||
}
|
||||
|
||||
// export const oauth2FlowTypes: OAuth2FlowType[] = Object.entries(OAuth2FlowType).map(([_, value]) => value)
|
||||
|
||||
export function isOAuth2FlowType(what: unknown): what is OAuth2FlowType {
|
||||
return [OAuth2FlowType.code].includes(what as any)
|
||||
}
|
||||
|
||||
export interface OAuth2Client {
|
||||
id: string
|
||||
display: string
|
||||
secret: string
|
||||
allowedFlows: OAuth2FlowType[]
|
||||
allowedScopeIds: string[]
|
||||
allowedRedirectUris: string[]
|
||||
}
|
||||
|
||||
export function isOAuth2Client(what: unknown): what is OAuth2Client {
|
||||
if ( typeof what !== 'object' || what === null ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!hasOwnProperty(what, 'id')
|
||||
|| !hasOwnProperty(what, 'display')
|
||||
|| !hasOwnProperty(what, 'secret')
|
||||
|| !hasOwnProperty(what, 'allowedFlows')
|
||||
|| !hasOwnProperty(what, 'allowedScopeIds')
|
||||
|| !hasOwnProperty(what, 'allowedRedirectUris')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( typeof what.id !== 'string' || typeof what.display !== 'string' || typeof what.secret !== 'string' ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !Array.isArray(what.allowedScopeIds) || !what.allowedScopeIds.every(x => typeof x === 'string') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !Array.isArray(what.allowedRedirectUris) || !what.allowedRedirectUris.every(x => typeof x === 'string') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(!Array.isArray(what.allowedFlows) || !what.allowedFlows.every(x => isOAuth2FlowType(x)))
|
||||
}
|
||||
|
||||
export abstract class ClientRepository {
|
||||
abstract find(id: string): Awaitable<Maybe<OAuth2Client>>
|
||||
}
|
||||
|
||||
export interface OAuth2Scope {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function isOAuth2Scope(what: unknown): what is OAuth2Scope {
|
||||
if ( typeof what !== 'object' || what === null ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !hasOwnProperty(what, 'id') || !hasOwnProperty(what, 'name') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( typeof what.id !== 'string' || typeof what.name !== 'string' ) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hasOwnProperty(what, 'description') || typeof what.description === 'string'
|
||||
}
|
||||
|
||||
export abstract class ScopeRepository {
|
||||
abstract find(id: string): Awaitable<Maybe<OAuth2Scope>>
|
||||
|
||||
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
|
||||
}
|
@ -167,6 +167,7 @@ export const Singleton = (name?: string): ClassDecorator => {
|
||||
*/
|
||||
export const FactoryProducer = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
logIfDebugging('extollo.di.injector', 'Registering factory producer for target:', target)
|
||||
if ( isInstantiable(target) ) {
|
||||
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import {Injectable, ScopedContainer, Container} from '../../di'
|
||||
import {infer, UniversalPath} from '../../util'
|
||||
import {Container, Injectable, ScopedContainer} from '../../di'
|
||||
import {HTTPStatus, infer, Pipeline, Safe, UniversalPath} from '../../util'
|
||||
import {IncomingMessage, ServerResponse} from 'http'
|
||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||
import {TLSSocket} from 'tls'
|
||||
import * as url from 'url'
|
||||
import {Response} from './Response'
|
||||
import * as Negotiator from 'negotiator'
|
||||
import {HTTPError} from '../HTTPError'
|
||||
|
||||
/**
|
||||
* Enumeration of different HTTP verbs.
|
||||
@ -193,6 +194,19 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a field from the request and wrap it in a safe-value accessor.
|
||||
* @param key
|
||||
*/
|
||||
public safe(key?: string): Safe {
|
||||
return Pipeline.id()
|
||||
.tap(val => new Safe(val))
|
||||
.tap(safe => safe.onError(message => {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid field (${key}): ${message}`)
|
||||
}))
|
||||
.apply(this.input(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UniversalPath instance for a file uploaded in the given field on the request.
|
||||
*/
|
||||
|
15
src/resources/views/oauth2/authorize.pug
Normal file
15
src/resources/views/oauth2/authorize.pug
Normal file
@ -0,0 +1,15 @@
|
||||
extends ../base
|
||||
|
||||
block content
|
||||
.offset(style='padding-top: 20vh')
|
||||
h3.login-heading Authorize #{clientName}?
|
||||
|
||||
if scopeDescription
|
||||
p This will allow #{clientName} to #{scopeDescription}.
|
||||
else
|
||||
p This will allow #{clientName} full access to your account.
|
||||
|
||||
//p After allowing this, you may not be prompted again.
|
||||
|
||||
if buttonText && buttonUrl
|
||||
a.button(href=buttonUrl) #{buttonText}
|
@ -31,6 +31,8 @@ export * from './support/path/Filesystem'
|
||||
export * from './support/path/LocalFilesystem'
|
||||
export * from './support/path/SSHFilesystem'
|
||||
|
||||
export * from './support/Safe'
|
||||
|
||||
export * from './support/Rehydratable'
|
||||
export * from './support/string'
|
||||
export * from './support/timeout'
|
||||
|
@ -47,6 +47,16 @@ export class Pipeline<TIn, TOut> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Like tap, but operates on a tuple with both the first value and the tapped value.
|
||||
* @param op
|
||||
*/
|
||||
first<T2>(op: PipeOperator<[TIn, TOut], T2>): Pipeline<TIn, T2> {
|
||||
return new Pipeline((val: TIn) => {
|
||||
return op([val, this.factory(val)])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Like tap, but always returns the original pipe type.
|
||||
* @param op
|
||||
|
61
src/util/support/Safe.ts
Normal file
61
src/util/support/Safe.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {Integer, isInteger} from './types'
|
||||
import {ErrorWithContext} from '../error/ErrorWithContext'
|
||||
|
||||
export class Safe {
|
||||
protected thrower: (message: string, value: unknown) => never
|
||||
|
||||
constructor(
|
||||
protected readonly value: unknown,
|
||||
) {
|
||||
this.thrower = (message) => {
|
||||
throw new ErrorWithContext('Invalid value', {
|
||||
message,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onError(thrower: (message: string, value: unknown) => never): this {
|
||||
this.thrower = thrower
|
||||
return this
|
||||
}
|
||||
|
||||
present(): this {
|
||||
if ( !this.value && this.value !== 0 && this.value !== false ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.thrower('Missing value', this.value)
|
||||
}
|
||||
|
||||
integer(): Integer {
|
||||
const value = parseInt(String(this.value), 10)
|
||||
if ( !isInteger(value) ) {
|
||||
this.thrower('Invalid integer', this.value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
number(): number {
|
||||
const value = parseFloat(String(this.value))
|
||||
if ( isNaN(value) ) {
|
||||
this.thrower('Invalid number', this.value)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
string(): string {
|
||||
this.present()
|
||||
return String(this.value)
|
||||
}
|
||||
|
||||
in<T>(allowed: T[]): T {
|
||||
if ( allowed.includes(this.value as any) ) {
|
||||
return this.value as T
|
||||
}
|
||||
|
||||
this.thrower('Invalid value', this.value)
|
||||
}
|
||||
}
|
@ -74,3 +74,9 @@ export type MethodsOf<T, TMethod = (...args: any[]) => any> = {
|
||||
}[keyof T]
|
||||
|
||||
export type Awaited<T> = T extends PromiseLike<infer U> ? U : T
|
||||
|
||||
export type Integer = TypeTag<'@extollo/lib.Integer'> & number
|
||||
|
||||
export function isInteger(num: number): num is Integer {
|
||||
return !isNaN(num) && parseInt(String(num), 10) === num
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user