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

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

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'