Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update OAuth flow for ArcGIS Plugin #225

Merged
merged 4 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion plugins/arcgis/service/src/ArcGISConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,14 @@ export interface UsernamePasswordAuthConfig {
* Contains OAuth authentication configuration.
*/
export interface OAuthAuthConfig {

type: AuthType.OAuth

/**
* The Client Id for OAuth
*/
clientId: string

/**
* The redirectUri for OAuth
*/
Expand All @@ -121,7 +124,7 @@ export interface OAuthAuthConfig {
*/
authToken?: string

/**
/**
* The expiration date for the temporary token
*/
authTokenExpires?: number
Expand Down
49 changes: 34 additions & 15 deletions plugins/arcgis/service/src/ArcGISIdentityManagerFactory.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ArcGISIdentityManager } from "@esri/arcgis-rest-request"
import { ArcGISIdentityManager, request } from "@esri/arcgis-rest-request"
import { ArcGISAuthConfig, AuthType, FeatureServiceConfig, OAuthAuthConfig, TokenAuthConfig, UsernamePasswordAuthConfig } from './ArcGISConfig'
import { HttpClient } from "./HttpClient";
import { ObservationProcessor } from "./ObservationProcessor";

interface ArcGISIdentityManagerFactory {
create(portal: string, server: string, config: ArcGISAuthConfig, httpClient?: HttpClient): Promise<ArcGISIdentityManager>
create(portal: string, server: string, config: ArcGISAuthConfig, processor?: ObservationProcessor): Promise<ArcGISIdentityManager>
}

const OAuthIdentityManagerFactory: ArcGISIdentityManagerFactory = {
async create(portal: string, server: string, auth: OAuthAuthConfig, httpClient: HttpClient): Promise<ArcGISIdentityManager> {
async create(portal: string, server: string, auth: OAuthAuthConfig, processor: ObservationProcessor): Promise<ArcGISIdentityManager> {
console.debug('Client ID provided for authentication')
const { clientId, authToken, authTokenExpires, refreshToken, refreshTokenExpires } = auth

Expand All @@ -22,14 +22,33 @@ const OAuthIdentityManagerFactory: ArcGISIdentityManagerFactory = {
} else if (refreshToken && new Date(refreshTokenExpires || 0) > new Date()) {
// TODO: find a way without using constructor nor httpClient
const url = `${portal}/oauth2/token?client_id=${clientId}&refresh_token=${refreshToken}&grant_type=refresh_token`
const response = await httpClient.sendGet(url)
// TODO: error handling
return ArcGISIdentityManager.fromToken({
clientId: clientId,
token: response.access_token,
portal: portal
});
// TODO: update authToken to new token
try {
const response = await request(url, {
httpMethod: 'GET'
});

// Update authToken to new token
const config = await processor.safeGetConfig();
let service = config.featureServices.find(service => service.url === portal)?.auth as OAuthAuthConfig;
const date = new Date();
date.setSeconds(date.getSeconds() + response.expires_in || 0);
service = {
...service,
authToken: response.access_token,
authTokenExpires: date.getTime()
}

await processor.putConfig(config)
return ArcGISIdentityManager.fromToken({
clientId: clientId,
token: response.access_token,
tokenExpires: date,
portal: portal
});
} catch (error) {
throw new Error('Error occurred when using refresh token')
}

} else {
// TODO the config, we need to let the user know UI side they need to authenticate again
throw new Error('Refresh token missing or expired')
Expand All @@ -46,7 +65,7 @@ const TokenIdentityManagerFactory: ArcGISIdentityManagerFactory = {
server: server,
// TODO: what do we really want to do here? esri package seems to need this optional parameter.
// Use authTokenExpires if defined, otherwise set to now plus a day
tokenExpires: auth.authTokenExpires ? new Date(auth.authTokenExpires) : new Date(Date.now() + 24 * 60 * 60 * 1000)
tokenExpires: auth.authTokenExpires ? new Date(auth.authTokenExpires) : new Date(Date.now() + 24 * 60 * 60 * 1000)
})
return identityManager
}
Expand All @@ -68,7 +87,7 @@ const authConfigMap: { [key: string]: ArcGISIdentityManagerFactory } = {

export function getIdentityManager(
config: FeatureServiceConfig,
httpClient: HttpClient // TODO remove in favor of an open source lib like axios
processor: ObservationProcessor
): Promise<ArcGISIdentityManager> {
const auth = config.auth
const authType = config.auth?.type
Expand All @@ -79,7 +98,7 @@ export function getIdentityManager(
if (!factory) {
throw new Error(`No factory found for type ${authType}`)
}
return factory.create(getPortalUrl(config.url), getServerUrl(config.url), auth, httpClient)
return factory.create(getPortalUrl(config.url), getServerUrl(config.url), auth, processor)
}


Expand Down
14 changes: 12 additions & 2 deletions plugins/arcgis/service/src/ObservationProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { EventTransform } from './EventTransform';
import { GeometryChangedHandler } from './GeometryChangedHandler';
import { EventDeletionHandler } from './EventDeletionHandler';
import { EventLayerProcessorOrganizer } from './EventLayerProcessorOrganizer';
import { FeatureServiceConfig, FeatureLayerConfig, AuthType } from "./ArcGISConfig"
import { FeatureServiceConfig, FeatureLayerConfig, AuthType, OAuthAuthConfig } from "./ArcGISConfig"
import { PluginStateRepository } from '@ngageoint/mage.service/lib/plugins.api'
import { FeatureServiceAdmin } from './FeatureServiceAdmin';

Expand Down Expand Up @@ -121,7 +121,9 @@ export class ObservationProcessor {
* @returns The current configuration from the database.
*/
public async safeGetConfig(): Promise<ArcGISPluginConfig> {
return await this._stateRepo.get().then(x => !!x ? x : this._stateRepo.put(defaultArcGISPluginConfig))
const state = await this._stateRepo.get();
if (!state) return await this._stateRepo.put(defaultArcGISPluginConfig);
return await this._stateRepo.get().then((state) => state ? state : this._stateRepo.put(defaultArcGISPluginConfig));
}

/**
Expand All @@ -132,6 +134,14 @@ export class ObservationProcessor {
return await this._stateRepo.put(newConfig);
}

/**
* Updates the confguration in the state repo.
* @param newConfig The new config to put into the state repo.
*/
public async patchConfig(newConfig: ArcGISPluginConfig): Promise<ArcGISPluginConfig> {
return await this._stateRepo.patch(newConfig);
}

/**
* Gets the current configuration and updates the processor if needed
* @returns The current configuration from the database.
Expand Down
34 changes: 22 additions & 12 deletions plugins/arcgis/service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import { SettingPermission } from '@ngageoint/mage.service/lib/entities/authoriz
import { ArcGISPluginConfig } from './ArcGISPluginConfig'
import { AuthType } from './ArcGISConfig'
import { ObservationProcessor } from './ObservationProcessor'
import { HttpClient } from './HttpClient'
import { ArcGISIdentityManager, request } from "@esri/arcgis-rest-request"
import { FeatureServiceConfig } from './ArcGISConfig'
import { FeatureServiceConfig, OAuthAuthConfig } from './ArcGISConfig'
import { URL } from "node:url"
import express from 'express'
import { getIdentityManager, getPortalUrl } from './ArcGISIdentityManagerFactory'
Expand Down Expand Up @@ -38,6 +37,19 @@ const InjectedServices = {

const pluginWebRoute = "plugins/@ngageoint/mage.arcgis.service"

const sanitizeFeatureService = (config: FeatureServiceConfig, type: AuthType): FeatureServiceConfig => {
if (type === AuthType.OAuth) {
const newAuth = Object.assign({}, config.auth) as OAuthAuthConfig;
delete newAuth.refreshToken;
delete newAuth.refreshTokenExpires;
return {
...config,
auth: newAuth
}
}
return config;
}

/**
* The MAGE ArcGIS Plugin finds new MAGE observations and if configured to send the observations
* to an ArcGIS server, it will then transform the observation to an ArcGIS feature and
Expand Down Expand Up @@ -120,7 +132,7 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
}

await processor.putConfig(config)

// TODO: This seems like a bad idea to send the access tokens to the front end. It has no use for them and could potentially be a security concern
newmanw marked this conversation as resolved.
Show resolved Hide resolved
res.send(`
<html>
<head>
Expand Down Expand Up @@ -151,13 +163,14 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
.get(async (req, res, next) => {
console.info('Getting ArcGIS plugin config...')
const config = await processor.safeGetConfig()
config.featureServices = config.featureServices.map((service) => sanitizeFeatureService(service, AuthType.OAuth));
res.json(config)
})
.put(async (req, res, next) => {
console.info('Applying ArcGIS plugin config...')
const arcConfig = req.body as ArcGISPluginConfig
const configString = JSON.stringify(arcConfig)
processor.putConfig(arcConfig)
processor.patchConfig(arcConfig)
res.sendStatus(200)
})

Expand All @@ -179,18 +192,16 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
}

try {
const httpClient = new HttpClient(console)
// Create the IdentityManager instance to validate credentials
await getIdentityManager(service!, httpClient)
await getIdentityManager(service!, processor)
let existingService = config.featureServices.find(service => service.url === url)
if (existingService) {
existingService = { ...existingService }
} else {
config.featureServices.push(service)
}

await processor.putConfig(config)
return res.send(service)
await processor.patchConfig(config)
return res.send(sanitizeFeatureService(service, AuthType.OAuth))
} catch (err) {
return res.send('Invalid credentials provided to communicate with feature service').status(400)
}
Expand All @@ -203,10 +214,9 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
if (!featureService) {
return res.status(400)
}

const httpClient = new HttpClient(console)

try {
const identityManager = await getIdentityManager(featureService, httpClient)
const identityManager = await getIdentityManager(featureService, processor)
const response = await request(url, {
authentication: identityManager
})
Expand Down
10 changes: 0 additions & 10 deletions plugins/arcgis/web-app/projects/main/src/lib/ArcGISConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,6 @@ export interface OAuthAuthConfig {
* The expiration date for the temporary token
*/
authTokenExpires?: string

/**
* The Refresh token for OAuth
*/
refreshToken?: string

/**
* The expiration date for the Refresh token
*/
refreshTokenExpires?: string
}

/**
Expand Down
18 changes: 10 additions & 8 deletions plugins/arcgis/web-app/projects/main/src/lib/arc.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,17 @@ export class ArcService implements ArcServiceInterface {
const oauthWindow = window.open(url, "_blank");

const listener = (event: any) => {
window.removeEventListener('message', listener, false);

if (event.origin !== window.location.origin) {
subject.error('target origin mismatch')
if (event.data.url) {
window.removeEventListener('message', listener, false);

if (event.origin !== window.location.origin) {
subject.error('target origin mismatch')
}

subject.next(event.data)

oauthWindow?.close();
}

subject.next(event.data)

oauthWindow?.close();
}

window.addEventListener('message', listener, false);
Expand Down
Loading