import {Controller} from '../../http/Controller' import {Inject, Injectable} from '../../di' import {ResponseObject, Route} from '../../http/routing/Route' import {Request} from '../../http/lifecycle/Request' import {Session} from '../../http/session/Session' import { ClientRepository, OAuth2Client, OAuth2FlowType, OAuth2Scope, RedemptionCodeRepository, ScopeRepository, TokenRepository, } from './types' import {HTTPError} from '../../http/HTTPError' import {HTTPStatus, 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' import {Logging} from '../../service/Logging' 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 { @Inject() protected readonly logging!: Logging public static routes(): void { Route.get('/oauth2/authorize') .alias('@oauth2:authorize') .pre(AuthRequiredMiddleware) .passingRequest() .calls(OAuth2Server, x => x.promptForAuthorization) Route.post('/oauth2/authorize') .alias('@oauth2:authorize:submit') .pre(AuthRequiredMiddleware) .passingRequest() .calls(OAuth2Server, x => x.authorizeAndRedirect) Route.post('/oauth2/token') .alias('@oauth2:token') .passingRequest() .calls(OAuth2Server, x => x.issue) } async issue(request: Request): Promise { const grant = request.safe('grant_type').in(grantTypes) const client = await this.getClientFromRequest(request) 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) } } 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() this.logging.verbose('Attempting password grant token issue...') this.logging.verbose({ scope, username, client, }) 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) if ( !code ) { 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) } this.logging.debug('Client auth parts:') this.logging.debug(authParts) const clientRepo = request.make(ClientRepository) const [clientId, clientSecret] = authParts const client = await clientRepo.find(clientId) this.logging.verbose('Client:') this.logging.verbose(client) 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 client = await this.getClientFromRequest(request) const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows) if ( flowType === OAuth2FlowType.code ) { return this.authorizeCodeFlow(request, client) } } protected async authorizeCodeFlow(request: Request, client: OAuth2Client): Promise { const session = request.make(Session) const security = request.make(SecurityContext) const codeRepository = request.make(RedemptionCodeRepository) const user = security.user() const scope = session.get('oauth2.authorize.scope') const redirectUri = session.safe('oauth2.authorize.redirectUri').in(client.allowedRedirectUris) // FIXME store authorization const code = await codeRepository.issue(user, client, scope) const uri = new URL(redirectUri) uri.searchParams.set('code', code.code) return redirect(uri) } async promptForAuthorization(request: Request): Promise { // Look up the client in the client repo const clientId = request.safe('client_id').string() const client = await this.getClient(request, clientId) // Make sure the requested flow type is valid for this client const session = request.make(Session) const flowType = request.safe('response_type').in(client.allowedFlows) const redirectUri = request.safe('redirect_uri').in(client.allowedRedirectUris) session.set('oauth2.authorize.clientId', client.id) session.set('oauth2.authorize.flow', flowType) session.set('oauth2.authorize.redirectUri', redirectUri) // Set the state if necessary const state = request.input('state') || '' if ( state ) { session.set('oauth2.authorize.state', String(state)) } else { session.forget('oauth2.authorize.state') } // If the request specified a scope, validate it and set it in the session const scope = await this.getScope(request, client) // Show a view prompting the user to approve the access return view('@extollo:oauth2:authorize', { clientName: client.display, scopeDescription: scope?.description, redirectDomain: (new URL(redirectUri)).host, }) } protected async getClient(request: Request, clientId: string): Promise { const clientRepo = request.make(ClientRepository) const client = await clientRepo.find(clientId) if ( !client ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', { clientId, }) } return client } protected async getScope(request: Request, client: OAuth2Client): Promise> { const session = request.make(Session) const scopeName = String(request.input('scope') || '') let scope: Maybe = undefined if ( scopeName ) { const scopeRepo = request.make(ScopeRepository) scope = await scopeRepo.findByName(scopeName) if ( !scope || !client.allowedScopeIds.includes(scope.id) ) { throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid scope', { scopeName, }) } session.set('oauth2.authorize.scope', scope.id) } else { session.forget('oauth2.authorize.state') } return scope } }