diff --git a/src/auth/index.ts b/src/auth/index.ts index b0f0256..a98e2ca 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -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' diff --git a/src/auth/server/OAuth2Server.ts b/src/auth/server/OAuth2Server.ts new file mode 100644 index 0000000..d18cc62 --- /dev/null +++ b/src/auth/server/OAuth2Server.ts @@ -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, x => x.promptForAuthorization.bind(x)) + } + + async promptForAuthorization(request: Request): Promise { + // 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 = 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 { + const clientRepo = request.make(ClientRepository) + const clientId = request.safe('client_id').string() + const client = await clientRepo.find(clientId) + if ( !client ) { + throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', { + clientId, + }) + } + + return client + } + + protected async getScope(request: Request, client: OAuth2Client): Promise> { + const session = request.make(Session) + const scopeName = String(request.input('scope') || '') + let scope: Maybe = undefined + if ( scopeName ) { + const scopeRepo = 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 + } +} diff --git a/src/auth/server/repositories/ClientRepositoryFactory.ts b/src/auth/server/repositories/ClientRepositoryFactory.ts new file mode 100644 index 0000000..ecf4dfb --- /dev/null +++ b/src/auth/server/repositories/ClientRepositoryFactory.ts @@ -0,0 +1,74 @@ +import { + AbstractFactory, + Container, + DependencyRequirement, + PropertyDependency, + isInstantiable, + DEPENDENCY_KEYS_METADATA_KEY, + DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer, +} from '../../../di' +import {Collection, ErrorWithContext} from '../../../util' +import {Config} from '../../../service/Config' +import {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 { + protected get config(): Config { + return Container.getContainer().make(Config) + } + + produce(): ClientRepository { + return new (this.getClientRepositoryClass())() + } + + match(something: unknown): boolean { + return something === ClientRepository + } + + getDependencyKeys(): Collection { + const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getClientRepositoryClass()) + if ( meta ) { + return meta + } + return new Collection() + } + + getInjectedProperties(): Collection { + const meta = new Collection() + 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 + */ + protected getClientRepositoryClass(): Instantiable { + 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 + } +} diff --git a/src/auth/server/repositories/ConfigClientRepository.ts b/src/auth/server/repositories/ConfigClientRepository.ts new file mode 100644 index 0000000..3240149 --- /dev/null +++ b/src/auth/server/repositories/ConfigClientRepository.ts @@ -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> { + const client = this.config.get(`oauth2.clients.${id}`) + if ( !isOAuth2Client(client) ) { + throw new ErrorWithContext('Invalid OAuth2 client configuration', { + id, + client, + }) + } + + return client + } +} diff --git a/src/auth/server/repositories/ConfigScopeRepository.ts b/src/auth/server/repositories/ConfigScopeRepository.ts new file mode 100644 index 0000000..9ec9930 --- /dev/null +++ b/src/auth/server/repositories/ConfigScopeRepository.ts @@ -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> { + const scope = this.config.get(`oauth2.scopes.${id}`) + if ( isOAuth2Scope(scope) ) { + return scope + } + } + + findByName(name: string): Awaitable> { + return this.find(name) + } +} diff --git a/src/auth/server/repositories/ScopeRepositoryFactory.ts b/src/auth/server/repositories/ScopeRepositoryFactory.ts new file mode 100644 index 0000000..47d5cee --- /dev/null +++ b/src/auth/server/repositories/ScopeRepositoryFactory.ts @@ -0,0 +1,74 @@ +import { + AbstractFactory, + Container, + DependencyRequirement, + PropertyDependency, + isInstantiable, + DEPENDENCY_KEYS_METADATA_KEY, + DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer, +} from '../../../di' +import {Collection, ErrorWithContext} from '../../../util' +import {Config} from '../../../service/Config' +import {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 { + protected get config(): Config { + return Container.getContainer().make(Config) + } + + produce(): ScopeRepository { + return new (this.getScopeRepositoryClass())() + } + + match(something: unknown): boolean { + return something === ScopeRepository + } + + getDependencyKeys(): Collection { + const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getScopeRepositoryClass()) + if ( meta ) { + return meta + } + return new Collection() + } + + getInjectedProperties(): Collection { + const meta = new Collection() + 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 + */ + protected getScopeRepositoryClass(): Instantiable { + 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 + } +} diff --git a/src/auth/server/types.ts b/src/auth/server/types.ts new file mode 100644 index 0000000..1f89edf --- /dev/null +++ b/src/auth/server/types.ts @@ -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> +} + +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> + + abstract findByName(name: string): Awaitable> +} diff --git a/src/di/decorator/injection.ts b/src/di/decorator/injection.ts index 58c6ae7..33d9aaf 100644 --- a/src/di/decorator/injection.ts +++ b/src/di/decorator/injection.ts @@ -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) } diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index c8daaf8..351372e 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -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. */ diff --git a/src/resources/views/oauth2/authorize.pug b/src/resources/views/oauth2/authorize.pug new file mode 100644 index 0000000..47535e6 --- /dev/null +++ b/src/resources/views/oauth2/authorize.pug @@ -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} diff --git a/src/util/index.ts b/src/util/index.ts index f36ff73..8504156 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -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' diff --git a/src/util/support/Pipe.ts b/src/util/support/Pipe.ts index ac8402f..dd8cf75 100644 --- a/src/util/support/Pipe.ts +++ b/src/util/support/Pipe.ts @@ -47,6 +47,16 @@ export class Pipeline { }) } + /** + * Like tap, but operates on a tuple with both the first value and the tapped value. + * @param op + */ + first(op: PipeOperator<[TIn, TOut], T2>): Pipeline { + return new Pipeline((val: TIn) => { + return op([val, this.factory(val)]) + }) + } + /** * Like tap, but always returns the original pipe type. * @param op diff --git a/src/util/support/Safe.ts b/src/util/support/Safe.ts new file mode 100644 index 0000000..b8ab848 --- /dev/null +++ b/src/util/support/Safe.ts @@ -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(allowed: T[]): T { + if ( allowed.includes(this.value as any) ) { + return this.value as T + } + + this.thrower('Invalid value', this.value) + } +} diff --git a/src/util/support/types.ts b/src/util/support/types.ts index 8fe5ced..ddb105a 100644 --- a/src/util/support/types.ts +++ b/src/util/support/types.ts @@ -74,3 +74,9 @@ export type MethodsOf any> = { }[keyof T] export type Awaited = T extends PromiseLike ? 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 +}