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 3 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(true);
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
32 changes: 29 additions & 3 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 @@ -120,8 +120,13 @@ export class ObservationProcessor {
* Gets the current configuration from the database.
* @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))
public async safeGetConfig(showFeatureAuth?: boolean): Promise<ArcGISPluginConfig> {
newmanw marked this conversation as resolved.
Show resolved Hide resolved
const state = await this._stateRepo.get();
if (!state) return await this._stateRepo.put(defaultArcGISPluginConfig);
if (!showFeatureAuth) {
state.featureServices = state.featureServices.map((service) => this.sanitizeFeatureService(service, AuthType.OAuth));
}
return state;
}

/**
Expand All @@ -132,6 +137,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 All @@ -151,6 +164,19 @@ export class ObservationProcessor {
return config
}

public sanitizeFeatureService(config: FeatureServiceConfig, type: AuthType): FeatureServiceConfig {
newmanw marked this conversation as resolved.
Show resolved Hide resolved
if (type === AuthType.OAuth) {
const newAuth = Object.assign({}, config.auth) as OAuthAuthConfig;
delete newAuth.refreshToken;
delete newAuth.refreshTokenExpires;
return {
...config,
auth: newAuth
}
}
return config;
}

/**
* Starts the processor.
*/
Expand Down
27 changes: 12 additions & 15 deletions plugins/arcgis/service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ 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 { URL } from "node:url"
Expand Down Expand Up @@ -75,7 +74,7 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
return res.status(404).send('clientId is required')
}

const config = await processor.safeGetConfig()
const config = await processor.safeGetConfig(true)
ArcGISIdentityManager.authorize({
clientId,
portal: getPortalUrl(url),
Expand All @@ -96,7 +95,7 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
return res.sendStatus(500)
}

const config = await processor.safeGetConfig()
const config = await processor.safeGetConfig(true)
const creds = {
clientId: state.clientId,
redirectUri: `${config.baseUrl}/${pluginWebRoute}/oauth/authenticate`,
Expand All @@ -120,7 +119,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 @@ -157,12 +156,12 @@ const arcgisPluginHooks: InitPluginHook<typeof InjectedServices> = {
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)
})

routes.post('/featureService/validate', async (req, res) => {
const config = await processor.safeGetConfig()
const config = await processor.safeGetConfig(true)
const { url, auth = {} } = req.body
const { token, username, password } = auth
if (!URL.canParse(url)) {
Expand All @@ -179,34 +178,32 @@ 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)
console.log('values patch')
await processor.patchConfig(config)
return res.send(processor.sanitizeFeatureService(service, AuthType.OAuth))
} catch (err) {
return res.send('Invalid credentials provided to communicate with feature service').status(400)
}
})

routes.get('/featureService/layers', async (req, res, next) => {
const url = req.query.featureServiceUrl as string
const config = await processor.safeGetConfig()
const config = await processor.safeGetConfig(true)
const featureService = config.featureServices.find(featureService => featureService.url === url)
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