OAuth2 stuff
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Garrett Mills 2021-10-18 12:48:16 -05:00
parent a1d04d652e
commit 3efbfecf9d
14 changed files with 397 additions and 94 deletions

View File

@ -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",

View File

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

View File

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

View File

@ -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 = <OAuth2LoginController> 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<ResponseObject> {
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
}
}

View File

@ -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<Authenticatable> {
throw new MethodNotSupportedError()
}
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
return this.getAuthenticatableFromBearer(credentials.credential)
}
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
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<Maybe<OAuth2User>> {
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<Maybe<OAuth2User>> {
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<ResponseObject> {
const state = uuid4()
await this.session.set('extollo.auth.oauth2.state', state)
return temporary(this.getRedirectUrl(state).toRemote)
}
}

50
src/auth/external/oauth2/OAuth2User.ts vendored Normal file
View File

@ -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<JSONState> {
return {
isOAuth2User: true,
data: this.data,
displayField: this.displayField,
identifierField: this.identifierField,
}
}
rehydrate(state: JSONState): Awaitable<void> {
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
}
}

View File

@ -22,3 +22,5 @@ export * from './config'
export * from './basic-ui/BasicLoginFormRequest'
export * from './basic-ui/BasicLoginController'
export * from './external/oauth2/OAuth2LoginController'

View File

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

View File

@ -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<void> {
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<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('sessions')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@ -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<void> {
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<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('users')
table.dropIfExists()
await schema.commit(table)
}
}

View File

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

View File

@ -1,3 +1,7 @@
import {RequestInfo, RequestInit, Response} from 'node-fetch'
import {unsafeESMImport} from './unsafe'
export const fetch = (url: RequestInfo, init?: RequestInit): Promise<Response> => 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'

View File

@ -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<UniversalPath> {
return Pipe.wrap(this)
}
}

24
src/util/unsafe.ts Normal file
View File

@ -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<any> {
((p: string) => p)(path)
return eval('import(path)') // eslint-disable-line no-eval
}