Add model serializer and coreid login provider

This commit is contained in:
2022-02-23 15:15:02 -06:00
parent 0774deea91
commit 70d67c2730
14 changed files with 360 additions and 39 deletions

View File

@@ -9,6 +9,10 @@ import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
import {ViewEngine} from '../views/ViewEngine'
import {SecurityContext} from './context/SecurityContext'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Config} from '../service/Config'
import {ErrorWithContext, hasOwnProperty} from '../util'
import {Route} from '../http/routing/Route'
@Injectable()
export class Authentication extends Unit {
@@ -18,6 +22,11 @@ export class Authentication extends Unit {
@Inject()
protected readonly middleware!: Middlewares
@Inject()
protected readonly config!: Config
protected providers: {[name: string]: LoginProvider<LoginProviderConfig>} = {}
async up(): Promise<void> {
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
@@ -27,6 +36,33 @@ export class Authentication extends Unit {
return () => req?.make<SecurityContext>(SecurityContext)?.getUser()
})
})
const config = this.config.get('auth.providers', {})
const middleware = this.config.get('auth.middleware', SessionAuthMiddleware)
if ( !(middleware?.prototype instanceof Middleware) ) {
throw new ErrorWithContext('Auth middleware must extend Middleware base class', {
providedValue: middleware,
configKey: 'auth.middleware',
})
}
for ( const name in config ) {
if ( !hasOwnProperty(config, name) ) {
continue
}
if ( this.providers[name] ) {
this.logging.warn(`Registering duplicate authentication provider: ${name}`)
}
this.logging.verbose(`Registered authentication provider: ${name}`)
this.providers[name] = this.make(config[name].driver, name, config[name].config)
Route.group(`/auth/${name}`, () => {
this.providers[name].routes()
}).pre(request => request.make<Middleware>(middleware, request).apply())
}
}
protected getMiddlewareResolver(): CanonicalResolver<StaticInstantiable<Middleware>> {

View File

@@ -1,12 +1,17 @@
import {Instantiable, isInstantiable} from '../di'
import {AuthenticatableRepository} from './types'
import {hasOwnProperty} from '../util'
import {LoginProviderConfig} from './provider/LoginProvider'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Middleware} from '../http/routing/Middleware'
export interface AuthenticationConfig {
storage: Instantiable<AuthenticatableRepository>,
middleware?: Instantiable<Middleware>,
providers?: {
[key: string]: LoginProviderConfig
[key: string]: {
driver: Instantiable<LoginProvider<LoginProviderConfig>>,
config: LoginProviderConfig,
},
},
}
@@ -15,7 +20,7 @@ export function isAuthenticationConfig(what: unknown): what is AuthenticationCon
return false
}
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'sources') ) {
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) {
return false
}
@@ -23,17 +28,21 @@ export function isAuthenticationConfig(what: unknown): what is AuthenticationCon
return false
}
if ( typeof what.sources !== 'object' ) {
if ( typeof what.providers !== 'object' ) {
return false
}
for ( const key in what.sources ) {
if ( !hasOwnProperty(what.sources, key) ) {
for ( const key in what.providers ) {
if ( !hasOwnProperty(what.providers, key) ) {
continue
}
const source = what.sources[key]
if ( !isInstantiable(source) || !(source.prototype instanceof AuthenticatableRepository) ) {
const source = what.providers[key]
if ( typeof source !== 'object' || source === null || !hasOwnProperty(source, 'driver') ) {
return false
}
if ( !isInstantiable(source.driver) || !(source.driver.prototype instanceof LoginProvider) ) {
return false
}
}

View File

@@ -11,6 +11,17 @@ export * from './event/UserAuthenticatedEvent'
export * from './event/UserAuthenticationResumedEvent'
export * from './event/UserFlushedEvent'
export * from './middleware/AuthRequiredMiddleware'
export * from './middleware/GuestRequiredMiddleware'
export * from './middleware/SessionAuthMiddleware'
export * from './provider/basic/BasicLoginAttempt'
export * from './provider/basic/BasicLoginProvider'
export * from './provider/basic/BasicRegistrationAttempt'
export * from './provider/oauth/OAuth2LoginProvider'
export * from './provider/oauth/CoreIDLoginProvider'
export * from './serial/AuthenticationEventSerializer'
export * from './repository/orm/ORMUser'

View File

@@ -5,6 +5,7 @@ import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {RequestLocalStorage} from '../../http/RequestLocalStorage'
export interface LoginProviderConfig {
default: boolean,
@@ -17,7 +18,11 @@ export interface LoginProviderConfig {
@Injectable()
export abstract class LoginProvider<TConfig extends LoginProviderConfig> {
@Inject()
protected readonly security!: SecurityContext
protected readonly request!: RequestLocalStorage
protected get security(): SecurityContext {
return this.request.get().make(SecurityContext)
}
constructor(
protected name: string,

View File

@@ -0,0 +1,100 @@
/* eslint camelcase: 0 */
import {OAuth2LoginProvider, OAuth2LoginProviderConfig} from './OAuth2LoginProvider'
import {Authenticatable} from '../../types'
import {Request} from '../../../http/lifecycle/Request'
import {ErrorWithContext, uuid4, fetch} from '../../../util'
/**
* OAuth2LoginProvider implementation that authenticates users against a
* Starship CoreID server.
*/
export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProviderConfig> {
protected async callback(request: Request): Promise<Authenticatable> {
// Get authentication_code from the request
const code = String(request.input('code') || '')
if ( !code ) {
throw new ErrorWithContext('Unable to authenticate user: missing login code', {
input: request.input(),
})
}
// Get OAuth2 token from CoreID
const token = await this.getToken(code)
// Get user from endpoint
const userData = await this.getUserData(token)
// Return authenticatable instance
const existing = await this.security.repository.getByIdentifier(userData.uid)
if ( existing ) {
this.updateUser(existing, userData)
return existing
}
const user = await this.security.repository.createFromCredentials(userData.uid, uuid4())
this.updateUser(user, userData)
return user
}
/** Given an access token, look up the associated user's information. */
protected async getUserData(token: string): Promise<any> {
const userResponse = await fetch(
this.config.userUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
)
const userData: any = await userResponse.json()
if ( !userData?.data?.uid ) {
throw new ErrorWithContext('Unable to extract user from response', {
userData,
})
}
return userData.data
}
/** Given a login code, redeem it for an access token. */
protected async getToken(code: string): Promise<string> {
const body: string[] = [
'code=' + encodeURIComponent(code),
'client_id=' + encodeURIComponent(this.config.clientId),
'client_secret=' + encodeURIComponent(this.config.clientSecret),
'grant_type=authorization_code',
]
const response = await fetch(
this.config.tokenUrl,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.join('&'),
},
)
const data = await response.json()
const token = (data as any).access_token
if ( !token ) {
throw new ErrorWithContext('Unable to obtain access token from response', {
data,
})
}
return String(token)
}
/** Update values on the Authenticatable from user data. */
protected updateUser(user: any, data: any): void {
user.firstName = data.first_name
user.lastName = data.last_name
user.email = data.email
user.tagline = data.tagline
user.photoUrl = data.profile_photo
}
}

View File

@@ -0,0 +1,98 @@
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {Inject, Injectable} from '../../../di'
import {Routing} from '../../../service/Routing'
import {GuestRequiredMiddleware} from '../../middleware/GuestRequiredMiddleware'
import {redirect} from '../../../http/response/RedirectResponseFactory'
import {view} from '../../../http/response/ViewResponseFactory'
import {Request} from '../../../http/lifecycle/Request'
import {Awaitable} from '../../../util'
import {Authenticatable} from '../../types'
export interface OAuth2LoginProviderConfig extends LoginProviderConfig {
displayName: string,
clientId: string|number
clientSecret: string
loginUrl: string
loginMessage?: string
logoutUrl?: string
tokenUrl: string,
userUrl: string,
}
/**
* LoginProvider implementation for OAuth2-based logins.
*/
@Injectable()
export abstract class OAuth2LoginProvider<TConfig extends OAuth2LoginProviderConfig> extends LoginProvider<TConfig> {
@Inject()
protected readonly routing!: Routing
public routes(): void {
super.routes()
Route.any('redirect')
.alias(`@auth:${this.name}:redirect`)
.pre(GuestRequiredMiddleware)
.handledBy(() => redirect(this.getLoginUrl()))
Route.any('callback')
.alias(`@auth:${this.name}:callback`)
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.handleCallback.bind(this))
}
protected async handleCallback(request: Request): Promise<ResponseObject> {
const user = await this.callback(request)
if ( user ) {
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
return redirect(this.routing.getNamedPath(`@auth:${this.name}:login`).toRemote)
}
/**
* After redirecting back from the OAuth2 server, look up the user information.
* @param request
* @protected
*/
protected abstract callback(request: Request): Awaitable<Authenticatable>
public login(): ResponseObject {
const buttonUrl = this.routing
.getNamedPath(`@auth:${this.name}:redirect`)
.toRemote
return view('@extollo:auth:message', {
message: this.config.loginMessage ?? `Sign-in with ${this.config.displayName} to continue`,
buttonText: 'Sign-in',
buttonUrl,
})
}
public async logout(): Promise<ResponseObject> {
await this.security.flush()
if ( this.config.logoutUrl ) {
return redirect(this.config.logoutUrl)
}
return view('@extollo:auth:message', {
message: 'You have been signed-out',
})
}
/**
* Get the URL where the user should be redirected to sign-in.
* @protected
*/
protected getLoginUrl(): string {
const callbackRoute = this.routing.getNamedPath(`@auth:${this.name}:callback`)
return this.config.loginUrl
.replace(/%c/g, String(this.config.clientId))
.replace(/%r/g, callbackRoute.toRemote)
}
}