Add Safe value API and start OAuth2Server

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

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