diff --git a/package.json b/package.json index 0467288..c43c9d7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "mime-types": "^2.1.31", "mkdirp": "^1.0.4", "negotiator": "^0.6.2", + "node-fetch": "^3", "pg": "^8.6.0", "pluralize": "^8.0.0", "pug": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c163f99..c087b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,7 @@ specifiers: mime-types: ^2.1.31 mkdirp: ^1.0.4 negotiator: ^0.6.2 + node-fetch: ^3 pg: ^8.6.0 pluralize: ^8.0.0 pug: ^3.0.2 @@ -66,6 +67,7 @@ dependencies: mime-types: 2.1.31 mkdirp: 1.0.4 negotiator: 0.6.2 + node-fetch: 3.0.0 pg: 8.6.0 pluralize: 8.0.0 pug: 3.0.2 @@ -793,6 +795,11 @@ packages: which: 2.0.2 dev: true + /data-uri-to-buffer/3.0.1: + resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} + engines: {node: '>= 6'} + dev: false + /debug/4.3.1: resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} engines: {node: '>=6.0'} @@ -1061,6 +1068,13 @@ packages: reusify: 1.0.4 dev: true + /fetch-blob/3.1.2: + resolution: {integrity: sha512-hunJbvy/6OLjCD0uuhLdp0mMPzP/yd2ssd1t2FCJsaA7wkWhpbp9xfuNVpv7Ll4jFhzp6T4LAupSiV9uOeg0VQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + web-streams-polyfill: 3.1.1 + dev: false + /figures/3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -1613,6 +1627,14 @@ packages: engines: {node: 4.x || >=6.0.0} dev: false + /node-fetch/3.0.0: + resolution: {integrity: sha512-bKMI+C7/T/SPU1lKnbQbwxptpCrG9ashG+VkytmXCPZyuM9jB6VU+hY0oi4lC8LxTtAeWdckNCTa3nrGsAdA3Q==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 3.0.1 + fetch-blob: 3.1.2 + dev: false + /nopt/5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -2456,6 +2478,11 @@ packages: defaults: 1.0.3 dev: false + /web-streams-polyfill/3.1.1: + resolution: {integrity: sha512-Czi3fG883e96T4DLEPRvufrF2ydhOOW1+1a6c3gNjH2aIh50DNFBdfwh2AKoOf1rXvpvavAoA11Qdq9+BKjE0Q==} + engines: {node: '>= 8'} + dev: false + /which/2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} diff --git a/src/auth/config.ts b/src/auth/config.ts index e51d2ac..8c082ad 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -1,5 +1,6 @@ import {Instantiable} from '../di' import {ORMUserRepository} from './orm/ORMUserRepository' +import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController' /** * Inferface for type-checking the AuthenticatableRepositories values. @@ -21,5 +22,8 @@ export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = { export interface AuthConfig { repositories: { session: keyof AuthenticatableRepositoryMapping, - } + }, + sources?: { + [key: string]: OAuth2LoginConfig, + }, } diff --git a/src/auth/external/oauth2/OAuth2LoginController.ts b/src/auth/external/oauth2/OAuth2LoginController.ts new file mode 100644 index 0000000..e8ac319 --- /dev/null +++ b/src/auth/external/oauth2/OAuth2LoginController.ts @@ -0,0 +1,95 @@ +import {Controller} from '../../../http/Controller' +import {Inject, Injectable} from '../../../di' +import {Config} from '../../../service/Config' +import {Request} from '../../../http/lifecycle/Request' +import {ResponseObject, Route} from '../../../http/routing/Route' +import {ErrorWithContext} from '../../../util' +import {OAuth2Repository} from './OAuth2Repository' +import {json} from '../../../http/response/JSONResponseFactory' + +export interface OAuth2LoginConfig { + name: string, + clientId: string, + clientSecret: string, + redirectUrl: string, + authorizationCodeField: string, + tokenEndpoint: string, + tokenEndpointMapping?: { + clientId?: string, + clientSecret?: string, + grantType?: string, + codeKey?: string, + }, + tokenEndpointResponseMapping?: { + token?: string, + expiresIn?: string, + expiresAt?: string, + }, + userEndpoint: string, + userEndpointResponseMapping?: { + identifier?: string, + display?: string, + }, +} + +export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig { + return ( + Boolean(what) + && typeof (what as any).name === 'string' + && typeof (what as any).clientId === 'string' + && typeof (what as any).clientSecret === 'string' + && typeof (what as any).redirectUrl === 'string' + && typeof (what as any).authorizationCodeField === 'string' + && typeof (what as any).tokenEndpoint === 'string' + && typeof (what as any).userEndpoint === 'string' + ) +} + +@Injectable() +export class OAuth2LoginController extends Controller { + public static routes(configName: string): void { + Route.group(`/auth/${configName}`, () => { + Route.get('login', (request: Request) => { + const controller = request.make(OAuth2LoginController, configName) + return controller.getLogin() + }).pre('@auth:guest') + }).pre('@auth:web') + } + + @Inject() + protected readonly config!: Config + + constructor( + protected readonly request: Request, + protected readonly configName: string, + ) { + super(request) + } + + public async getLogin(): Promise { + const repo = this.getRepository() + if ( repo.shouldRedirect() ) { + return repo.redirect() + } + + // We were redirected from the auth source + const user = await repo.redeem() + return json(user) + } + + protected getRepository(): OAuth2Repository { + return this.request.make(OAuth2Repository, this.getConfig()) + } + + protected getConfig(): OAuth2LoginConfig { + const config = this.config.get(`auth.sources.${this.configName}`) + if ( !isOAuth2LoginConfig(config) ) { + throw new ErrorWithContext('Invalid OAuth2 source config.', { + configName: this.configName, + config, + }) + } + + return config + } +} diff --git a/src/auth/external/oauth2/OAuth2Repository.ts b/src/auth/external/oauth2/OAuth2Repository.ts new file mode 100644 index 0000000..9024623 --- /dev/null +++ b/src/auth/external/oauth2/OAuth2Repository.ts @@ -0,0 +1,156 @@ +import { + Authenticatable, + AuthenticatableCredentials, + AuthenticatableIdentifier, + AuthenticatableRepository, +} from '../../types' +import {Inject, Injectable} from '../../../di' +import { + Awaitable, + dataGetUnsafe, + fetch, + Maybe, + MethodNotSupportedError, + UniversalPath, + universalPath, + uuid4, +} from '../../../util' +import {OAuth2LoginConfig} from './OAuth2LoginController' +import {Session} from '../../../http/session/Session' +import {ResponseObject} from '../../../http/routing/Route' +import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory' +import {Request} from '../../../http/lifecycle/Request' +import {Logging} from '../../../service/Logging' +import {OAuth2User} from './OAuth2User' + +@Injectable() +export class OAuth2Repository implements AuthenticatableRepository { + @Inject() + protected readonly session!: Session + + @Inject() + protected readonly request!: Request + + @Inject() + protected readonly logging!: Logging + + constructor( + protected readonly config: OAuth2LoginConfig, + ) { } + + public createByCredentials(): Awaitable { + throw new MethodNotSupportedError() + } + + getByCredentials(credentials: AuthenticatableCredentials): Awaitable> { + return this.getAuthenticatableFromBearer(credentials.credential) + } + + getByIdentifier(id: AuthenticatableIdentifier): Awaitable> { + return undefined + } + + public getRedirectUrl(state?: string): UniversalPath { + const url = universalPath(this.config.redirectUrl) + if ( state ) { + url.query.append('state', state) + } + + return url + } + + public getTokenEndpoint(): UniversalPath { + return universalPath(this.config.tokenEndpoint) + } + + public getUserEndpoint(): UniversalPath { + return universalPath(this.config.userEndpoint) + } + + public async redeem(): Promise> { + if ( !this.stateIsValid() ) { + return // FIXME throw + } + + const body = new URLSearchParams() + + if ( this.config.tokenEndpointMapping ) { + if ( this.config.tokenEndpointMapping.clientId ) { + body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId) + } + + if ( this.config.tokenEndpointMapping.clientSecret ) { + body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret) + } + + if ( this.config.tokenEndpointMapping.codeKey ) { + body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField))) + } + + if ( this.config.tokenEndpointMapping.grantType ) { + body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code') + } + } + + this.logging.debug(`Redeeming auth code: ${body.toString()}`) + + const response = await fetch(this.getTokenEndpoint().toRemote, { + method: 'post', + body: body, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + }) + + const data = await response.json() + if ( typeof data !== 'object' || data === null ) { + throw new Error() + } + + this.logging.debug(data) + const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer')) + + this.logging.debug(bearer) + if ( !bearer || typeof bearer !== 'string' ) { + throw new Error() + } + + return this.getAuthenticatableFromBearer(bearer) + } + + public async getAuthenticatableFromBearer(bearer: string): Promise> { + const response = await fetch(this.getUserEndpoint().toRemote, { + method: 'get', + headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${bearer}`, + }, + }) + + const data = await response.json() + if ( typeof data !== 'object' || data === null ) { + throw new Error() + } + + return new OAuth2User(data, this.config) + } + + public stateIsValid(): boolean { + const correctState = this.session.get('extollo.auth.oauth2.state', '') + const inputState = this.request.input('state') || '' + return correctState === inputState + } + + public shouldRedirect(): boolean { + const codeField = this.config.authorizationCodeField + const code = this.request.input(codeField) + return !code + } + + public async redirect(): Promise { + const state = uuid4() + await this.session.set('extollo.auth.oauth2.state', state) + return temporary(this.getRedirectUrl(state).toRemote) + } +} diff --git a/src/auth/external/oauth2/OAuth2User.ts b/src/auth/external/oauth2/OAuth2User.ts new file mode 100644 index 0000000..bbe901c --- /dev/null +++ b/src/auth/external/oauth2/OAuth2User.ts @@ -0,0 +1,50 @@ +import {Authenticatable, AuthenticatableIdentifier} from '../../types' +import {OAuth2LoginConfig} from './OAuth2LoginController' +import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util' + +export class OAuth2User implements Authenticatable { + protected displayField: string + + protected identifierField: string + + constructor( + protected data: {[key: string]: any}, + config: OAuth2LoginConfig, + ) { + this.displayField = config.userEndpointResponseMapping?.display || 'name' + this.identifierField = config.userEndpointResponseMapping?.identifier || 'id' + } + + getDisplayIdentifier(): string { + return String(dataGetUnsafe(this.data, this.displayField || 'name', '')) + } + + getIdentifier(): AuthenticatableIdentifier { + return String(dataGetUnsafe(this.data, this.identifierField || 'id', '')) + } + + async dehydrate(): Promise { + return { + isOAuth2User: true, + data: this.data, + displayField: this.displayField, + identifierField: this.identifierField, + } + } + + rehydrate(state: JSONState): Awaitable { + if ( + !state.isOAuth2User + || typeof state.data !== 'object' + || state.data === null + || typeof state.displayField !== 'string' + || typeof state.identifierField !== 'string' + ) { + throw new InvalidJSONStateError('OAuth2User state is invalid', { state }) + } + + this.data = state.data + this.identifierField = state.identifierField + this.displayField = state.identifierField + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index e230344..4340aca 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -22,3 +22,5 @@ export * from './config' export * from './basic-ui/BasicLoginFormRequest' export * from './basic-ui/BasicLoginController' + +export * from './external/oauth2/OAuth2LoginController' diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index c1d0073..c8daaf8 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -108,6 +108,7 @@ export class Request extends ScopedContainer implements DataContainer { protected serverResponse: ServerResponse, ) { super(Container.getContainer()) + this.registerSingletonInstance(Request, this) this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted) @@ -124,12 +125,6 @@ export class Request extends ScopedContainer implements DataContainer { minor: clientRequest.httpVersionMinor, } - this.register(Request) - this.instances.push({ - key: Request, - value: this, - }) - const parts = url.parse(this.url, true) this.path = parts.pathname ?? '/' diff --git a/src/migrations/2021-07-23T19:44:00.000Z_CreateSessionsTable.migration.ts b/src/migrations/2021-07-23T19:44:00.000Z_CreateSessionsTable.migration.ts deleted file mode 100644 index 0c70d02..0000000 --- a/src/migrations/2021-07-23T19:44:00.000Z_CreateSessionsTable.migration.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {Inject, Injectable} from '../di' -import {ConstraintType, DatabaseService, FieldType, Migration, Schema} from '../orm' - -/** - * Migration that creates the sessions table used by the ORMSession backend. - */ -@Injectable() -export default class CreateSessionsTableMigration extends Migration { - @Inject() - protected readonly db!: DatabaseService - - async up(): Promise { - const schema: Schema = this.db.get().schema() - const table = await schema.table('sessions') - - table.primaryKey('session_uuid', FieldType.varchar) - .required() - - table.column('session_data') - .type(FieldType.json) - .required() - .default('{}') - - table.constraint('session_uuid_ck') - .type(ConstraintType.Check) - .expression('LENGTH(session_uuid) > 0') - - await schema.commit(table) - } - - async down(): Promise { - const schema: Schema = this.db.get().schema() - const table = await schema.table('sessions') - - table.dropIfExists() - - await schema.commit(table) - } -} diff --git a/src/migrations/2021-07-24T10:31:00.000Z_CreateUsersTable.migration.ts b/src/migrations/2021-07-24T10:31:00.000Z_CreateUsersTable.migration.ts deleted file mode 100644 index 1070172..0000000 --- a/src/migrations/2021-07-24T10:31:00.000Z_CreateUsersTable.migration.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {Inject, Injectable} from '../di' -import {DatabaseService, FieldType, Migration, Schema} from '../orm' - -/** - * Migration that creates the users table used by @extollo/lib.auth. - */ -@Injectable() -export default class CreateUsersTableMigration extends Migration { - @Inject() - protected readonly db!: DatabaseService - - async up(): Promise { - const schema: Schema = this.db.get().schema() - const table = await schema.table('users') - - table.primaryKey('user_id') - .required() - - table.column('first_name') - .type(FieldType.varchar) - .nullable() - - table.column('last_name') - .type(FieldType.varchar) - .nullable() - - table.column('password_hash') - .type(FieldType.text) - .nullable() - - table.column('username') - .type(FieldType.varchar) - .required() - .unique() - - await schema.commit(table) - } - - async down(): Promise { - const schema: Schema = this.db.get().schema() - const table = await schema.table('users') - - table.dropIfExists() - - await schema.commit(table) - } -} diff --git a/src/util/error/MethodNotSupportedError.ts b/src/util/error/MethodNotSupportedError.ts new file mode 100644 index 0000000..d4b65aa --- /dev/null +++ b/src/util/error/MethodNotSupportedError.ts @@ -0,0 +1,10 @@ +import {ErrorWithContext} from './ErrorWithContext' + +export class MethodNotSupportedError extends ErrorWithContext { + constructor( + message = 'Method not supported', + context: {[key: string]: any} = {}, + ) { + super(message, context) + } +} diff --git a/src/util/index.ts b/src/util/index.ts index 889395f..f36ff73 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,3 +1,7 @@ +import {RequestInfo, RequestInit, Response} from 'node-fetch' +import {unsafeESMImport} from './unsafe' +export const fetch = (url: RequestInfo, init?: RequestInit): Promise => unsafeESMImport('node-fetch').then(({default: nodeFetch}) => nodeFetch(url, init)) + export * from './cache/Cache' export * from './cache/InMemCache' @@ -10,6 +14,7 @@ export * from './collection/where' export * from './const/http' export * from './error/ErrorWithContext' +export * from './error/MethodNotSupportedError' export * from './logging/Logger' export * from './logging/StandardLogger' diff --git a/src/util/support/path.ts b/src/util/support/path.ts index 6a4dadb..6ae6afe 100644 --- a/src/util/support/path.ts +++ b/src/util/support/path.ts @@ -5,6 +5,7 @@ import * as mime from 'mime-types' import {FileNotFoundError, Filesystem} from './path/Filesystem' import {Collection} from '../collection/Collection' import {Readable, Writable} from 'stream' +import {Pipe} from './Pipe' /** * An item that could represent a path. @@ -82,6 +83,8 @@ export class UniversalPath { protected resourceLocalPath!: string + protected resourceQuery: URLSearchParams = new URLSearchParams() + constructor( /** * The path string this path refers to. @@ -94,6 +97,10 @@ export class UniversalPath { ) { this.setPrefix() this.setLocal() + + if ( this.isRemote ) { + this.resourceQuery = (new URL(this.toRemote)).searchParams + } } /** @@ -140,6 +147,13 @@ export class UniversalPath { return new UniversalPath(this.initial) } + /** + * Get the URLSearchParams for this resource. + */ + get query(): URLSearchParams { + return this.resourceQuery + } + /** * Get the string of this resource. */ @@ -183,7 +197,8 @@ export class UniversalPath { * Get the fully-prefixed path to this resource. */ get toRemote(): string { - return `${this.prefix}${this.resourceLocalPath}` + const query = this.query.toString() + return `${this.prefix}${this.resourceLocalPath}${query ? '?' + query : ''}` } /** @@ -517,4 +532,9 @@ export class UniversalPath { return false } + + /** Get a new Pipe instance wrapping this. */ + toPipe(): Pipe { + return Pipe.wrap(this) + } } diff --git a/src/util/unsafe.ts b/src/util/unsafe.ts new file mode 100644 index 0000000..5236062 --- /dev/null +++ b/src/util/unsafe.ts @@ -0,0 +1,24 @@ +/** + * UNSAFE + * + * Sometimes, we need to make a literal `import()` call from within commonJS + * modules in order to pull in ES modules from commonJS. + * + * However, when tsc renders the modules to commonJS, it rewrites _all_ calls + * to `import` as calls to `require`, which means we cannot actually use ES + * modules from commonJS-transpiled TypeScript. + * + * To bypass this, we can eval the literal string. This is a stupid hack and + * I hate it so much, but unfortunately it works. + * + * So, this is a wrapper function that results in a call to the literal + * `import(...)` function in the transpiled code. It should be used VERY + * sparingly. + * + * @see https://github.com/microsoft/TypeScript/issues/43329 + * @param path + */ +export function unsafeESMImport(path: string): Promise { + ((p: string) => p)(path) + return eval('import(path)') // eslint-disable-line no-eval +}