diff --git a/src/auth/context/TokenSecurityContext.ts b/src/auth/context/TokenSecurityContext.ts new file mode 100644 index 0000000..4dbd74a --- /dev/null +++ b/src/auth/context/TokenSecurityContext.ts @@ -0,0 +1,41 @@ +import {SecurityContext} from './SecurityContext' +import {AuthenticatableRepository} from '../types' +import {Awaitable} from '../../util' +import {Inject} from '../../di' +import {Request} from '../../http/lifecycle/Request' +import {OAuth2Token, TokenRepository} from '../server/types' +import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent' + +export class TokenSecurityContext extends SecurityContext { + @Inject() + protected readonly request!: Request + + @Inject() + protected readonly tokens!: TokenRepository + + constructor( + public readonly repository: AuthenticatableRepository, + ) { + super(repository, 'token') + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + persist(): Awaitable {} + + async resume(): Promise { + if ( !this.request.hasInstance(OAuth2Token) ) { + return + } + + const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token) + if ( !token.userId ) { + return + } + + const user = await this.repository.getByIdentifier(token.userId) + if ( user ) { + this.authenticatedUser = user + await this.bus.push(new UserAuthenticationResumedEvent(user, this)) + } + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index d2d5c09..e666ade 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -2,9 +2,11 @@ export * from './types' export * from './AuthenticatableAlreadyExistsError' export * from './NotAuthorizedError' export * from './Authentication' +export * from './repository/AuthenticatableRepositoryFactory' export * from './context/SecurityContext' export * from './context/SessionSecurityContext' +export * from './context/TokenSecurityContext' export * from './event/AuthenticationEvent' export * from './event/UserAuthenticatedEvent' @@ -14,6 +16,8 @@ export * from './event/UserFlushedEvent' export * from './middleware/AuthRequiredMiddleware' export * from './middleware/GuestRequiredMiddleware' export * from './middleware/SessionAuthMiddleware' +export * from './middleware/TokenAuthMiddleware' +export * from './middleware/ScopeRequiredMiddleware' export * from './provider/basic/BasicLoginAttempt' export * from './provider/basic/BasicLoginProvider' diff --git a/src/auth/middleware/ScopeRequiredMiddleware.ts b/src/auth/middleware/ScopeRequiredMiddleware.ts new file mode 100644 index 0000000..72d12dc --- /dev/null +++ b/src/auth/middleware/ScopeRequiredMiddleware.ts @@ -0,0 +1,33 @@ +import {Middleware} from '../../http/routing/Middleware' +import {ResponseObject} from '../../http/routing/Route' +import {OAuth2Token} from '../server/types' +import {HTTPError} from '../../http/HTTPError' +import {HTTPStatus, Pipeline} from '../../util' +import {Request} from '../../http/lifecycle/Request' +import {Constructable, Container} from '../../di' + +export class ScopeRequiredMiddleware extends Middleware { + constructor( + request: Request, + protected readonly scope: string, + ) { + super(request) + } + + apply(): ResponseObject { + if ( !this.request.hasInstance(OAuth2Token) ) { + throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Must specify an OAuth2 token.') + } + + const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token) + if ( typeof token.scope !== 'undefined' && token.scope !== this.scope ) { + throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Insufficient token permissions (requires: ' + this.scope + ')') + } + } +} + +export const scope = (name: string): Constructable => { + return new Pipeline( + container => container.make(ScopeRequiredMiddleware, name), + ) +} diff --git a/src/auth/middleware/SessionAuthMiddleware.ts b/src/auth/middleware/SessionAuthMiddleware.ts index 52aad9c..de59beb 100644 --- a/src/auth/middleware/SessionAuthMiddleware.ts +++ b/src/auth/middleware/SessionAuthMiddleware.ts @@ -1,10 +1,8 @@ import {Middleware} from '../../http/routing/Middleware' -import {Inject, Injectable, Instantiable} from '../../di' +import {Inject, Injectable} from '../../di' import {Config} from '../../service/Config' import {Logging} from '../../service/Logging' import {AuthenticatableRepository} from '../types' -import {Maybe} from '../../util' -import {AuthenticationConfig, isAuthenticationConfig} from '../config' import {ResponseObject} from '../../http/routing/Route' import {SessionSecurityContext} from '../context/SessionSecurityContext' import {SecurityContext} from '../context/SecurityContext' @@ -23,22 +21,9 @@ export class SessionAuthMiddleware extends Middleware { async apply(): Promise { this.logging.debug('Applying session auth middleware.') - const context = this.make(SessionSecurityContext, this.getRepository()) + const repo = this.make(AuthenticatableRepository) + const context = this.make(SessionSecurityContext, repo) this.request.registerSingletonInstance(SecurityContext, context) await context.resume() } - - /** - * Build the correct AuthenticatableRepository based on the auth config. - * @protected - */ - protected getRepository(): AuthenticatableRepository { - const config: Maybe = this.config.get('auth') - if ( !isAuthenticationConfig(config) ) { - throw new TypeError('Invalid authentication config.') - } - - const repo: Instantiable = config.storage - return this.make(repo) - } } diff --git a/src/auth/middleware/TokenAuthMiddleware.ts b/src/auth/middleware/TokenAuthMiddleware.ts new file mode 100644 index 0000000..480554f --- /dev/null +++ b/src/auth/middleware/TokenAuthMiddleware.ts @@ -0,0 +1,45 @@ +import {Middleware} from '../../http/routing/Middleware' +import {Inject, Injectable} from '../../di' +import {Config} from '../../service/Config' +import {Logging} from '../../service/Logging' +import {AuthenticatableRepository} from '../types' +import {ResponseObject} from '../../http/routing/Route' +import {SecurityContext} from '../context/SecurityContext' +import {TokenSecurityContext} from '../context/TokenSecurityContext' +import {OAuth2Token, oauth2TokenString, TokenRepository} from '../server/types' + +/** + * Injects a TokenSecurityContext into the request and attempts to + * resume the user's authentication. + */ +@Injectable() +export class TokenAuthMiddleware extends Middleware { + @Inject() + protected readonly config!: Config + + @Inject() + protected readonly logging!: Logging + + @Inject() + protected readonly tokens!: TokenRepository + + async apply(): Promise { + this.logging.debug('Applying token auth middleware.') + let tokenString = this.request.getHeader('Authorization') + if ( Array.isArray(tokenString) ) { + tokenString = tokenString[0] + } + + if ( tokenString ) { + const token = await this.tokens.decode(oauth2TokenString(tokenString)) + if ( token ) { + this.request.registerSingletonInstance(OAuth2Token, token) + } + } + + const repo = this.make(AuthenticatableRepository) + const context = this.make(TokenSecurityContext, repo) + this.request.registerSingletonInstance(SecurityContext, context) + await context.resume() + } +} diff --git a/src/auth/repository/AuthenticatableRepositoryFactory.ts b/src/auth/repository/AuthenticatableRepositoryFactory.ts new file mode 100644 index 0000000..8d32a5a --- /dev/null +++ b/src/auth/repository/AuthenticatableRepositoryFactory.ts @@ -0,0 +1,73 @@ +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 {AuthenticatableRepository} from '../types' +import {ORMUserRepository} from './orm/ORMUserRepository' + +/** + * A dependency injection factory that matches the abstract ClientRepository class + * and produces an instance of the configured repository driver implementation. + */ +@FactoryProducer() +export class AuthenticatableRepositoryFactory extends AbstractFactory { + protected get config(): Config { + return Container.getContainer().make(Config) + } + + produce(): AuthenticatableRepository { + return new (this.getAuthenticatableRepositoryClass())() + } + + match(something: unknown): boolean { + return something === AuthenticatableRepository + } + + getDependencyKeys(): Collection { + const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getAuthenticatableRepositoryClass()) + if ( meta ) { + return meta + } + return new Collection() + } + + getInjectedProperties(): Collection { + const meta = new Collection() + let currentToken = this.getAuthenticatableRepositoryClass() + + 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 user repository backend. + * @protected + */ + protected getAuthenticatableRepositoryClass(): Instantiable { + const AuthenticatableRepositoryClass = this.config.get('auth.storage', ORMUserRepository) + + if ( !isInstantiable(AuthenticatableRepositoryClass) || !(AuthenticatableRepositoryClass.prototype instanceof AuthenticatableRepository) ) { + const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.AuthenticatableRepository') + e.context = { + configKey: 'auth.storage', + class: AuthenticatableRepositoryClass.toString(), + } + } + + return AuthenticatableRepositoryClass + } +} diff --git a/src/auth/server/OAuth2Server.ts b/src/auth/server/OAuth2Server.ts index f22b8d8..14cb1f9 100644 --- a/src/auth/server/OAuth2Server.ts +++ b/src/auth/server/OAuth2Server.ts @@ -10,13 +10,24 @@ import { OAuth2Scope, RedemptionCodeRepository, ScopeRepository, + TokenRepository, } from './types' import {HTTPError} from '../../http/HTTPError' -import {HTTPStatus, Maybe} from '../../util' +import {Awaitable, HTTPStatus, left, Maybe} from '../../util' import {view} from '../../http/response/ViewResponseFactory' import {SecurityContext} from '../context/SecurityContext' import {redirect} from '../../http/response/RedirectResponseFactory' import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware' +import {one} from '../../http/response/api' +import {AuthenticatableRepository} from '../types' + +export enum GrantType { + Client = 'client_credentials', + Password = 'password', + Code = 'authorization_code', +} + +export const grantTypes: GrantType[] = [GrantType.Client, GrantType.Code, GrantType.Password] @Injectable() export class OAuth2Server extends Controller { @@ -33,25 +44,47 @@ export class OAuth2Server extends Controller { .passingRequest() .calls(OAuth2Server, x => x.authorizeAndRedirect) - Route.post('/oauth2/redeem') - .alias('@oauth2:authorize:redeem') + Route.post('/oauth2/token') + .alias('@oauth2:token') + .parameterMiddleware(async req => + left(req.make(OAuth2Server).getClientFromRequest(req))) .passingRequest() - .calls(OAuth2Server, x => x.redeemToken) + .calls(OAuth2Server, x => x.issue) } - async redeemToken(request: Request): Promise { - const authParts = String(request.getHeader('Authorization')).split(':') - if ( authParts.length !== 2 ) { - throw new HTTPError(HTTPStatus.BAD_REQUEST) + issue(request: Request, client: OAuth2Client): Awaitable { + const grant = request.safe('grant_type') + .in(grantTypes) + + if ( grant === GrantType.Client ) { + return this.issueFromClient(request, client) + } else if ( grant === GrantType.Code ) { + return this.issueFromCode(request, client) + } else if ( grant === GrantType.Password ) { + return this.issueFromCredential(request, client) } + } - const clientRepo = request.make(ClientRepository) - const [clientId, clientSecret] = authParts - const client = await clientRepo.find(clientId) - if ( !client || client.secret !== clientSecret ) { - throw new HTTPError(HTTPStatus.UNAUTHORIZED) + protected async issueFromCredential(request: Request, client: OAuth2Client): Promise { + const scope = String(this.request.input('scope') ?? '') || undefined + const username = this.request.safe('username').string() + const password = this.request.safe('password').string() + + const userRepo = request.make(AuthenticatableRepository) + const user = await userRepo.getByIdentifier(username) + if ( !user || !(await user.validateCredential(password)) ) { + throw new HTTPError(HTTPStatus.BAD_REQUEST) } + const tokenRepo = request.make(TokenRepository) + const token = await tokenRepo.issue(user, client, scope) + return one({ + token: await tokenRepo.encode(token), + }) + } + + protected async issueFromCode(request: Request, client: OAuth2Client): Promise { + const scope = String(this.request.input('scope') ?? '') || undefined const codeRepo = request.make(RedemptionCodeRepository) const codeString = request.safe('code').string() const code = await codeRepo.find(codeString) @@ -59,15 +92,49 @@ export class OAuth2Server extends Controller { throw new HTTPError(HTTPStatus.BAD_REQUEST) } + const userRepo = request.make(AuthenticatableRepository) + const user = await userRepo.getByIdentifier(code.userId) + if ( !user ) { + throw new HTTPError(HTTPStatus.BAD_REQUEST) + } + + const tokenRepo = request.make(TokenRepository) + const token = await tokenRepo.issue(user, client, scope) + return one({ + token: await tokenRepo.encode(token), + }) + } + + protected async issueFromClient(request: Request, client: OAuth2Client): Promise { + const scope = String(this.request.input('scope') ?? '') || undefined + const tokenRepo = request.make(TokenRepository) + const token = await tokenRepo.issue(undefined, client, scope) + return one({ + token: await tokenRepo.encode(token), + }) + } + + protected async getClientFromRequest(request: Request): Promise { + const authParts = String(request.getHeader('Authorization')).split(':') + if ( authParts.length !== 2 ) { + throw new HTTPError(HTTPStatus.BAD_REQUEST) + } + + const clientRepo = request.make(ClientRepository) + const [clientId, clientSecret] = authParts + const client = await clientRepo.find(clientId) + if ( !client || client.secret !== clientSecret ) { + throw new HTTPError(HTTPStatus.UNAUTHORIZED) + } + + return client } async authorizeAndRedirect(request: Request): Promise { // Look up the client in the client repo const session = request.make(Session) - const clientId = session.safe('oauth2.authorize.clientId').string() - const client = await this.getClient(request, clientId) - + const client = await this.getClientFromRequest(request) const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows) if ( flowType === OAuth2FlowType.code ) { return this.authorizeCodeFlow(request, client) diff --git a/src/auth/server/repositories/ORMTokenRepository.ts b/src/auth/server/repositories/ORMTokenRepository.ts index 675f395..9fa99c8 100644 --- a/src/auth/server/repositories/ORMTokenRepository.ts +++ b/src/auth/server/repositories/ORMTokenRepository.ts @@ -20,17 +20,19 @@ export class ORMTokenRepository extends TokenRepository { } } - async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise { + async issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Promise { 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)) + if ( user ) { + token.userId = String(user.getUniqueIdentifier()) + } await token.save() return token @@ -40,10 +42,10 @@ export class ORMTokenRepository extends TokenRepository { 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.userId ? { userId: token.userId } : {}), ...(token.scope ? { scope: token.scope } : {}), } @@ -74,10 +76,10 @@ export class ORMTokenRepository extends TokenRepository { const value = { id: decoded.id, - userId: decoded.userId, clientId: decoded.clientId, issued: new Date(decoded.iat * 1000), expires: new Date(decoded.exp * 1000), + ...(decoded.userId ? { userId: decoded.userId } : {}), ...(decoded.scope ? { scope: decoded.scope } : {}), } diff --git a/src/auth/server/types.ts b/src/auth/server/types.ts index 628fe89..1f83eca 100644 --- a/src/auth/server/types.ts +++ b/src/auth/server/types.ts @@ -83,13 +83,19 @@ export abstract class ScopeRepository { abstract findByName(name: string): Awaitable> } -export interface OAuth2Token { - id: string - userId: AuthenticatableIdentifier - clientId: string - issued: Date - expires: Date - scope?: string +export abstract class OAuth2Token { + abstract id: string + + /** When undefined, these are client credentials. */ + abstract userId?: AuthenticatableIdentifier + + abstract clientId: string + + abstract issued: Date + + abstract expires: Date + + abstract scope?: string } export type OAuth2TokenString = TypeTag<'@extollo/lib.OAuth2TokenString'> & string @@ -105,7 +111,6 @@ export function isOAuth2Token(what: unknown): what is OAuth2Token { if ( !hasOwnProperty(what, 'id') - || !hasOwnProperty(what, 'userId') || !hasOwnProperty(what, 'clientId') || !hasOwnProperty(what, 'issued') || !hasOwnProperty(what, 'expires') @@ -115,7 +120,7 @@ export function isOAuth2Token(what: unknown): what is OAuth2Token { if ( typeof what.id !== 'string' - || !(typeof what.userId === 'string' || typeof what.userId === 'number') + || (hasOwnProperty(what, 'userId') && !(typeof what.userId === 'string' || typeof what.userId === 'number')) || typeof what.clientId !== 'string' || !(what.issued instanceof Date) || !(what.expires instanceof Date) @@ -129,7 +134,7 @@ export function isOAuth2Token(what: unknown): what is OAuth2Token { export abstract class TokenRepository { abstract find(id: string): Awaitable> - abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable + abstract issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Awaitable abstract decode(token: OAuth2TokenString): Awaitable> diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index 6c1fc12..2766a39 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -109,6 +109,20 @@ export class Response { return this } + /** + * Remove a header from the response by name. + * @param name + */ + public unsetHeader(name: string): this { + this.logging.verbose(`Will unset header on response: ${name}`) + if ( this.sentHeaders ) { + throw new HeadersAlreadySentError(this, name) + } + + delete this.headers[name] + return this + } + /** * Bulk set the specified headers in the response. * @param data diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 292a624..7b480cc 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -372,21 +372,37 @@ export class Route } - public pre(middleware: Instantiable): this { - this.preflight.prepend(request => request.make(middleware, request).apply()) + public pre(middleware: Instantiable|Constructable): this { + let name: string + if ( middleware instanceof Pipeline ) { + this.preflight.prepend(request => middleware.apply(request).apply()) + name = '(unknown pipeline)' + } else { + this.preflight.prepend(request => request.make(middleware, request).apply()) + name = middleware.name + } + this.displays.push({ stage: 'pre', - display: `${middleware.name}`, + display: name, }) return this } - public post(middleware: Instantiable): this { - this.postflight.push(request => request.make(middleware, request).apply()) + public post(middleware: Instantiable|Constructable): this { + let name: string + if ( middleware instanceof Pipeline ) { + this.postflight.push(request => middleware.apply(request).apply()) + name = '(unknown pipeline)' + } else { + this.preflight.push(request => request.make(middleware, request).apply()) + name = middleware.name + } + this.displays.push({ - stage: 'pre', - display: `${middleware.name}`, + stage: 'post', + display: name, }) return this