Add Safe value API and start OAuth2Server

This commit is contained in:
Garrett Mills 2022-02-24 00:00:35 -06:00
parent 70d67c2730
commit a039b1ff25
14 changed files with 474 additions and 2 deletions

View File

@ -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'

View 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
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View 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
View 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>>
}

View File

@ -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)
}

View File

@ -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.
*/

View 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}

View File

@ -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'

View File

@ -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
View 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)
}
}

View File

@ -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
}