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

WIP: started with Actions OAuth #5639

Closed
Closed
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
13 changes: 11 additions & 2 deletions api/models/schema/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,17 @@ const actionSchema = new Schema({
// json_schema: Schema.Types.Mixed,
privacy_policy_url: String,
raw_spec: String,
oauth_client_id: String, // private, encrypted
oauth_client_secret: String, // private, encrypted
oauth_client_id: String, // private, encrypted
oauth_client_secret: String, // private, encrypted

// New fields for storing OAuth tokens after user authentication:
oauth_access_token: {
type: String,
},
oauth_refresh_token: {
type: String,
},
oauth_token_expires_at: Date,
},
});
// }, { minimize: false }); // Prevent removal of empty objects
Expand Down
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const startServer = async () => {
app.use('/oauth', routes.oauth);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
Expand Down
119 changes: 119 additions & 0 deletions api/server/routes/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const fetch = require('node-fetch');
const express = require('express');
const { nanoid } = require('nanoid');
const jwt = require('jsonwebtoken');
const { updateAction, getActions } = require('~/models/Action');
const { decryptMetadata } = require('~/server/services/ActionService');

const router = express.Router();

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; // Use a secure secret in production

/**
* Initiates OAuth login flow for the specified action.
*
* @route GET /actions/:action_id/oauth/login
* @param {string} req.params.action_id - The ID of the action.
* @returns {void} Redirects the user to the OAuth provider's login URL.
*/
router.get('/:action_id/oauth/login', async (req, res) => {
const { action_id } = req.params;
const [action] = await getActions({ action_id }, true);
if (!action) {
return res.status(404).send('Action not found');
}

let metadata = await decryptMetadata(action.metadata);

const statePayload = {
nonce: nanoid(),
action_id,
};

const stateToken = jwt.sign(statePayload, JWT_SECRET, { expiresIn: '10m' });

const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`;
const params = new URLSearchParams({
client_id: metadata.oauth_client_id,
redirect_uri: redirectUri,
scope: action.metadata.auth.scope,
state: stateToken,
});

const authUrl = `${action.metadata.auth.authorization_url}?${params.toString()}`;
res.redirect(authUrl);
});

/**
* Handles the OAuth callback and exchanges the authorization code for tokens.
*
* @route GET /actions/:action_id/oauth/callback
* @param {string} req.params.action_id - The ID of the action.
* @param {string} req.query.code - The authorization code returned by the provider.
* @param {string} req.query.state - The state token to verify the authenticity of the request.
* @returns {void} Sends a success message after updating the action with OAuth tokens.
*/
router.get('/:action_id/oauth/callback', async (req, res) => {
const { action_id } = req.params;
const { code, state } = req.query;

let decodedState;
try {
decodedState = jwt.verify(state, JWT_SECRET);
} catch (err) {
return res.status(400).send('Invalid or expired state parameter');
}
if (decodedState.action_id !== action_id) {
return res.status(400).send('Mismatched action ID in state parameter');
}
const [action] = await getActions({ action_id }, true);
if (!action) {
return res.status(404).send('Action not found');
}

let metadata = await decryptMetadata(action.metadata);

// Token exchange
const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`;
const body = new URLSearchParams({
client_id: metadata.oauth_client_id,
client_secret: metadata.oauth_client_secret,
code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});

const tokenResp = await fetch(action.metadata.auth.client_url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body,
});

if (!tokenResp.ok) {
return res
.status(tokenResp.status)
.send(`Error exchanging code: ${await tokenResp.text()}`);
}

const tokenJson = await tokenResp.json();
const { access_token, refresh_token, expires_in } = tokenJson;

const updateData = {
$set: {
'metadata.oauth_access_token': access_token,
'metadata.oauth_refresh_token': refresh_token,
},
};

if (expires_in) {
updateData.$set['metadata.oauth_token_expires_at'] = new Date(Date.now() + expires_in * 1000);
}

await updateAction({ action_id }, updateData);

res.send('Authentication successful. You can close this window and return to your chat.');
});
module.exports = router;
2 changes: 2 additions & 0 deletions api/server/routes/agents/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ const { encryptMetadata, domainParser } = require('~/server/services/ActionServi
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { getAgent, updateAgent } = require('~/models/Agent');
const fetch = require('node-fetch');
const { logger } = require('~/config');
const { HttpsProxyAgent } = require('https-proxy-agent');

const router = express.Router();

Expand Down
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const prompts = require('./prompts');
const balance = require('./balance');
const plugins = require('./plugins');
const bedrock = require('./bedrock');
const actions = require('./actions');
const search = require('./search');
const models = require('./models');
const convos = require('./convos');
Expand Down Expand Up @@ -45,6 +46,7 @@ module.exports = {
config,
models,
plugins,
actions,
presets,
balance,
messages,
Expand Down
29 changes: 27 additions & 2 deletions client/src/components/Chat/Messages/Content/ToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ export default function ToolCall({
};
}, [name]);

const error =
typeof output === 'string' && output.toLowerCase().includes('error processing tool');
const error = typeof output === 'string' && output.toLowerCase().includes('error processing tool');
const needsLogin =
typeof output === 'string' &&
(output.toLowerCase().includes('no access token') ||
output.toLowerCase().includes('expired token'));

const args = useMemo(() => {
if (typeof _args === 'string') {
Expand Down Expand Up @@ -134,6 +137,28 @@ export default function ToolCall({
function_name={function_name}
/>
)}

{/*TODO: fix hardcoded action_id*/}
{needsLogin && 'Ty2HxVutaMeSDbxkryip6' && (
<div className="pl-7 flex flex-col">
<div className="text-sm text-gray-600 mb-1">
{/* Here we use the original domain value */}
{localize('com_assistants_wants_to_talk', domain ?? '' )}
</div>
<a
className="inline-flex w-fit items-center justify-center rounded-full bg-black px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
href={'/api/actions/Ty2HxVutaMeSDbxkryip6/oauth/login'}
target="_blank"
rel="noopener noreferrer"
>
{/* And here as well */}
{localize('com_assistants_sign_in_with_domain', domain ?? '')}
</a>
<p className="mt-1 text-xs text-gray-500">
{localize('com_assistants_allow_sites_you_trust')}
</p>
</div>
)}
</div>
{attachments?.map((attachment, index) => (
<Attachment attachment={attachment} key={index} />
Expand Down
9 changes: 4 additions & 5 deletions client/src/components/SidePanel/Agents/ActionsAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,15 @@ export default function ActionsAuth({
API Key
</label>
</div>
<div className="flex items-center gap-2 text-gray-500">
<label htmlFor=":rfc:" className="flex cursor-not-allowed items-center gap-1">
<div className="flex items-center gap-2">
<label htmlFor=":rfc:" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
disabled={true}
value={AuthTypeEnum.OAuth}
id=":rfc:"
className="mr-1 flex h-5 w-5 cursor-not-allowed items-center justify-center rounded-full border border-gray-500 bg-gray-300 dark:border-gray-600 dark:bg-gray-700"
tabIndex={-1}
className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500"
tabIndex={0}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-gray-950 dark:bg-white"></RadioGroup.Indicator>
</RadioGroup.Item>
Expand Down
9 changes: 9 additions & 0 deletions client/src/localization/languages/Eng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ export default {
com_assistants_completed_function: 'Ran {0}',
com_assistants_function_use: 'Assistant used {0}',
com_assistants_domain_info: 'Assistant sent this info to {0}',
com_assistants_needs_login: 'Sign in required',
com_assistants_wants_to_talk: 'wants to talk to {0}',
com_assistants_sign_in_with_domain: 'Sign in with {0}',
com_assistants_allow_sites_you_trust: 'Only allow sites you trust.',
com_assistants_error_missing_token: 'No access token found. Please sign in first.',
com_assistants_error_expired_token: 'Access token expired. Please sign in again.',
com_assistants_authenticating: 'Authenticating with {0}...',
com_assistants_login_successful: 'Authentication successful! You can close this window.',
com_assistants_login_failed: 'Authentication failed. Please try again.',
com_assistants_delete_actions_success: 'Successfully deleted Action from Assistant',
com_assistants_update_actions_success: 'Successfully created or updated Action',
com_assistants_update_actions_error: 'There was an error creating or updating the action.',
Expand Down
33 changes: 18 additions & 15 deletions packages/data-provider/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ class RequestExecutor {
/* OAuth */
oauth_client_id,
oauth_client_secret,
oauth_access_token,
oauth_token_expires_at,
} = metadata;

const isApiKey = api_key != null && api_key.length > 0 && type === AuthTypeEnum.ServiceHttp;
Expand Down Expand Up @@ -230,22 +232,23 @@ class RequestExecutor {
) {
this.authHeaders[custom_auth_header] = api_key;
} else if (isOAuth) {
const authToken = this.authToken ?? '';
if (!authToken) {
const tokenResponse = await axios.post(
client_url,
{
client_id: oauth_client_id,
client_secret: oauth_client_secret,
scope: scope,
grant_type: 'client_credentials',
},
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
this.authToken = tokenResponse.data.access_token;
// TODO: maybe doing it in a different way later on. but we want that the user needs to folllow the oauth flow.
// If we do not have a valid token, bail or ask user to sign in
const now = new Date();

// 1. Check if token is set
if (!oauth_access_token) {
throw new Error('No access token found. Please log in first.');
}

// 2. Check if token is expired
if (oauth_token_expires_at && now >= new Date(oauth_token_expires_at)) {
// Optionally check refresh_token logic, or just prompt user to re-login
throw new Error('Access token is expired. Please re-login.');
}

// If valid, use it
this.authToken = oauth_access_token;
this.authHeaders['Authorization'] = `Bearer ${this.authToken}`;
}
return this;
Expand Down
3 changes: 3 additions & 0 deletions packages/data-provider/src/types/assistants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,9 @@ export type ActionMetadata = {
raw_spec?: string;
oauth_client_id?: string;
oauth_client_secret?: string;
oauth_access_token?: string;
oauth_refresh_token?: string;
oauth_token_expires_at?: Date;
};

/* Assistant types */
Expand Down
Loading