From 00c9446023628dc8c0e8c8fdefde687a442d2e55 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Tue, 5 Aug 2025 07:34:26 -0700 Subject: [PATCH] endpoint to get and update onepassword token (#3089) --- skyvern-frontend/src/api/types.ts | 18 ++ .../src/components/OnePasswordTokenForm.tsx | 142 ++++++++++++++ .../src/hooks/useOnePasswordToken.ts | 62 ++++++ .../src/routes/settings/Settings.tsx | 20 ++ skyvern/forge/sdk/db/client.py | 23 +++ skyvern/forge/sdk/routes/credentials.py | 121 +++++++++++- skyvern/forge/sdk/schemas/organizations.py | 21 +- skyvern/forge/sdk/services/credentials.py | 181 ------------------ skyvern/forge/sdk/workflow/context_manager.py | 18 +- 9 files changed, 420 insertions(+), 186 deletions(-) create mode 100644 skyvern-frontend/src/components/OnePasswordTokenForm.tsx create mode 100644 skyvern-frontend/src/hooks/useOnePasswordToken.ts diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index d88ed4f5..b938d4b4 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -175,6 +175,24 @@ export type ApiKeyApiResponse = { valid: boolean; }; +export type OnePasswordTokenApiResponse = { + id: string; + organization_id: string; + token: string; + created_at: string; + modified_at: string; + token_type: string; + valid: boolean; +}; + +export type CreateOnePasswordTokenRequest = { + token: string; +}; + +export type CreateOnePasswordTokenResponse = { + token: OnePasswordTokenApiResponse; +}; + // TODO complete this export const ActionTypes = { InputText: "input_text", diff --git a/skyvern-frontend/src/components/OnePasswordTokenForm.tsx b/skyvern-frontend/src/components/OnePasswordTokenForm.tsx new file mode 100644 index 00000000..155643fd --- /dev/null +++ b/skyvern-frontend/src/components/OnePasswordTokenForm.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useOnePasswordToken } from "@/hooks/useOnePasswordToken"; +import { EyeOpenIcon, EyeClosedIcon } from "@radix-ui/react-icons"; + +const formSchema = z.object({ + token: z.string().min(1, "1Password token is required"), +}); + +type FormData = z.infer; + +export function OnePasswordTokenForm() { + const [showToken, setShowToken] = useState(false); + const { onePasswordToken, isLoading, createOrUpdateToken, isUpdating } = + useOnePasswordToken(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + token: onePasswordToken?.token || "", + }, + }); + + const onSubmit = (data: FormData) => { + createOrUpdateToken(data); + }; + + const toggleTokenVisibility = () => { + setShowToken(!showToken); + }; + + // Update form when token data loads + if ( + onePasswordToken?.token && + form.getValues("token") !== onePasswordToken.token + ) { + form.setValue("token", onePasswordToken.token); + } + + return ( +
+
+
+

+ 1Password Service Account Token +

+

+ Configure your 1Password service account token for credential + management. +

+
+ {onePasswordToken && ( +
+ Status: + + {onePasswordToken.valid ? "Active" : "Inactive"} + +
+ )} +
+ +
+ + ( + + Service Account Token +
+ + + + +
+ +
+ )} + /> + +
+ + {onePasswordToken && ( +
+ Last updated:{" "} + {new Date(onePasswordToken.modified_at).toLocaleDateString()} +
+ )} +
+ + + + {onePasswordToken && ( +
+

Token Information

+
+
ID: {onePasswordToken.id}
+
Type: {onePasswordToken.token_type}
+
+ Created:{" "} + {new Date(onePasswordToken.created_at).toLocaleDateString()} +
+
+
+ )} +
+ ); +} diff --git a/skyvern-frontend/src/hooks/useOnePasswordToken.ts b/skyvern-frontend/src/hooks/useOnePasswordToken.ts new file mode 100644 index 00000000..3d3b075b --- /dev/null +++ b/skyvern-frontend/src/hooks/useOnePasswordToken.ts @@ -0,0 +1,62 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "./useCredentialGetter"; +import { + CreateOnePasswordTokenRequest, + CreateOnePasswordTokenResponse, + OnePasswordTokenApiResponse, +} from "@/api/types"; +import { useToast } from "@/components/ui/use-toast"; + +export function useOnePasswordToken() { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data: onePasswordToken, isLoading } = + useQuery({ + queryKey: ["onePasswordToken"], + queryFn: async () => { + const client = await getClient(credentialGetter); + return await client + .get("/auth-tokens/onepassword") + .then((response) => response.data.token) + .catch(() => null); + }, + }); + + const createOrUpdateTokenMutation = useMutation({ + mutationFn: async (data: CreateOnePasswordTokenRequest) => { + const client = await getClient(credentialGetter); + return await client + .post("/auth-tokens/onepassword", data) + .then((response) => response.data as CreateOnePasswordTokenResponse); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["onePasswordToken"] }); + toast({ + title: "Success", + description: "1Password service account token updated successfully", + }); + }, + onError: (error: unknown) => { + const message = + (error as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail || + (error as Error)?.message || + "Failed to update 1Password token"; + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + }); + + return { + onePasswordToken, + isLoading, + createOrUpdateToken: createOrUpdateTokenMutation.mutate, + isUpdating: createOrUpdateTokenMutation.isPending, + }; +} diff --git a/skyvern-frontend/src/routes/settings/Settings.tsx b/skyvern-frontend/src/routes/settings/Settings.tsx index 200c411b..1d28778e 100644 --- a/skyvern-frontend/src/routes/settings/Settings.tsx +++ b/skyvern-frontend/src/routes/settings/Settings.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/card"; import { envCredential } from "@/util/env"; import { HiddenCopyableInput } from "@/components/ui/hidden-copyable-input"; +import { OnePasswordTokenForm } from "@/components/OnePasswordTokenForm"; function Settings() { const { environment, organization, setEnvironment, setOrganization } = @@ -67,6 +68,25 @@ function Settings() { + + + 1Password Integration + + Manage your 1Password service account token.{" "} + + Learn how to create a service account and get your token. + + + + + + + ); } diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index a3bc4393..b9744ede 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -967,6 +967,29 @@ class AgentDB: return await convert_to_organization_auth_token(auth_token) + async def invalidate_org_auth_tokens( + self, + organization_id: str, + token_type: OrganizationAuthTokenType, + ) -> None: + """Invalidate all existing tokens of a specific type for an organization.""" + try: + async with self.Session() as session: + await session.execute( + update(OrganizationAuthTokenModel) + .filter_by(organization_id=organization_id) + .filter_by(token_type=token_type) + .filter_by(valid=True) + .values(valid=False) + ) + await session.commit() + except SQLAlchemyError: + LOG.error("SQLAlchemyError", exc_info=True) + raise + except Exception: + LOG.error("UnexpectedError", exc_info=True) + raise + async def get_artifacts_for_task_v2( self, task_v2_id: str, diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index 5af14200..8b301735 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -3,6 +3,7 @@ from fastapi import Body, Depends, HTTPException, Path, Query from skyvern.forge import app from skyvern.forge.prompts import prompt_engine +from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType from skyvern.forge.sdk.routes.code_samples import ( CREATE_CREDENTIAL_CODE_SAMPLE, CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD, @@ -19,7 +20,11 @@ from skyvern.forge.sdk.schemas.credentials import ( CreditCardCredentialResponse, PasswordCredentialResponse, ) -from skyvern.forge.sdk.schemas.organizations import Organization +from skyvern.forge.sdk.schemas.organizations import ( + CreateOnePasswordTokenRequest, + CreateOnePasswordTokenResponse, + Organization, +) from skyvern.forge.sdk.schemas.totp_codes import TOTPCode, TOTPCodeCreate from skyvern.forge.sdk.services import org_auth_service from skyvern.forge.sdk.services.bitwarden import BitwardenService @@ -367,3 +372,117 @@ async def get_credentials( ) ) return response_items + + +@base_router.get( + "/credentials/onepassword", + response_model=CreateOnePasswordTokenResponse, + summary="Get OnePassword service account token", + description="Retrieves the current OnePassword service account token for the organization.", + tags=["Auth Tokens"], + openapi_extra={ + "x-fern-sdk-method-name": "get_onepassword_token", + }, +) +@base_router.get( + "/credentials/onepassword/", + response_model=CreateOnePasswordTokenResponse, + include_in_schema=False, +) +async def get_onepassword_token( + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CreateOnePasswordTokenResponse: + """ + Get the current OnePassword service account token for the organization. + """ + try: + auth_token = await app.DATABASE.get_valid_org_auth_token( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.onepassword_service_account, + ) + + if not auth_token: + raise HTTPException( + status_code=404, + detail="No OnePassword service account token found for this organization", + ) + + return CreateOnePasswordTokenResponse(token=auth_token) + + except HTTPException: + raise + except Exception as e: + LOG.error( + "Failed to get OnePassword service account token", + organization_id=current_org.organization_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to get OnePassword service account token: {str(e)}", + ) + + +@base_router.post( + "/credentials/onepassword", + response_model=CreateOnePasswordTokenResponse, + summary="Create or update OnePassword service account token", + description="Creates or updates a OnePassword service account token for the current organization. Only one valid token is allowed per organization.", + tags=["Auth Tokens"], + openapi_extra={ + "x-fern-sdk-method-name": "update_onepassword_token", + }, +) +@base_router.post( + "/credentials/onepassword/", + response_model=CreateOnePasswordTokenResponse, + include_in_schema=False, +) +async def update_onepassword_token( + data: CreateOnePasswordTokenRequest = Body( + ..., + description="The OnePassword token data", + openapi_extra={"x-fern-sdk-parameter-name": "data"}, + ), + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CreateOnePasswordTokenResponse: + """ + Create or update a OnePassword service account token for the current organization. + + This endpoint ensures only one valid OnePassword token exists per organization. + If a valid token already exists, it will be invalidated before creating the new one. + """ + try: + # Invalidate any existing valid OnePassword tokens for this organization + await app.DATABASE.invalidate_org_auth_tokens( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.onepassword_service_account, + ) + + # Create the new token + auth_token = await app.DATABASE.create_org_auth_token( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.onepassword_service_account, + token=data.token, + ) + + LOG.info( + "Created or updated OnePassword service account token", + organization_id=current_org.organization_id, + token_id=auth_token.id, + ) + + return CreateOnePasswordTokenResponse(token=auth_token) + + except Exception as e: + LOG.error( + "Failed to create or update OnePassword service account token", + organization_id=current_org.organization_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to create or update OnePassword service account token: {str(e)}", + ) diff --git a/skyvern/forge/sdk/schemas/organizations.py b/skyvern/forge/sdk/schemas/organizations.py index 2b449424..bd6dff18 100644 --- a/skyvern/forge/sdk/schemas/organizations.py +++ b/skyvern/forge/sdk/schemas/organizations.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType @@ -31,6 +31,25 @@ class OrganizationAuthToken(BaseModel): modified_at: datetime +class CreateOnePasswordTokenRequest(BaseModel): + """Request model for creating or updating a 1Password service account token.""" + + token: str = Field( + ..., + description="The 1Password service account token", + examples=["op_1234567890abcdef"], + ) + + +class CreateOnePasswordTokenResponse(BaseModel): + """Response model for 1Password token operations.""" + + token: OrganizationAuthToken = Field( + ..., + description="The created or updated 1Password service account token", + ) + + class GetOrganizationsResponse(BaseModel): organizations: list[Organization] diff --git a/skyvern/forge/sdk/services/credentials.py b/skyvern/forge/sdk/services/credentials.py index a4d23653..e4cca78c 100644 --- a/skyvern/forge/sdk/services/credentials.py +++ b/skyvern/forge/sdk/services/credentials.py @@ -1,11 +1,5 @@ -import json import logging from enum import StrEnum -from typing import Optional - -from onepassword.client import Client as OnePasswordClient - -from skyvern.config import settings LOG = logging.getLogger(__name__) @@ -14,178 +8,3 @@ class OnePasswordConstants(StrEnum): """Constants for 1Password integration.""" TOTP = "OP_TOTP" # Special value to indicate a TOTP code - - -async def resolve_secret(vault_id: str, item_id: str) -> str: - """ - Resolve a 1Password secret using vault_id and item_id directly. - - Args: - vault_id: The 1Password vault ID - item_id: The 1Password item ID - - Returns: - The resolved secret value - """ - token = settings.OP_SERVICE_ACCOUNT_TOKEN - if not token: - raise ValueError("OP_SERVICE_ACCOUNT_TOKEN not configured in settings") - - client = await OnePasswordClient.authenticate( - auth=token, - integration_name="Skyvern 1Password", - integration_version="v1.0.0", - ) - - result = await get_1password_item_details(client, vault_id, item_id) - return result - - -async def get_1password_item_details(client: OnePasswordClient, vault_id: str, item_id: str) -> str: - """ - Get details of a 1Password item. - - Args: - client: Authenticated 1Password client - vault_id: The vault ID - item_id: The item ID - - Returns: - JSON string containing item fields and their values - """ - try: - item = await client.items.get(vault_id, item_id) - - # Check if item is None - if item is None: - LOG.error(f"No item found for vault_id:{vault_id}, item_id:{item_id}") - raise ValueError(f"1Password item not found: vault_id:{vault_id}, item_id:{item_id}") - - # Create a dictionary of all fields - result = {} - - # Debug: Log the structure of the item and fields - LOG.info( - f"1Password item structure: {dir(item)}" - + (f"\nFirst field structure: {dir(item.fields[0])}" if hasattr(item, "fields") and item.fields else "") - ) - # We don't log field values as they may contain sensitive credentials - - # Add all fields with proper attribute checking - for i, field in enumerate(item.fields): - # Debug: Log each field's structure - LOG.debug(f"Field {i} structure: {dir(field)}") - - if hasattr(field, "value") and field.value is not None: - # Safely get field identifier - use id attribute or fallback to a default - try: - # Try different possible attribute names for the field identifier - field_id = None - - # Check all available attributes on the field object - field_attrs = dir(field) - LOG.debug(f"Field {i} attributes: {field_attrs}") - - # Try to get the most appropriate identifier - if hasattr(field, "id") and field.id: - field_id = field.id - LOG.debug(f"Using field.id: {field_id}") - elif hasattr(field, "name") and field.name: - field_id = field.name - LOG.debug(f"Using field.name: {field_id}") - elif hasattr(field, "label") and field.label: - field_id = field.label - LOG.debug(f"Using field.label: {field_id}") - elif hasattr(field, "type") and field.type: - field_id = f"{field.type}_{i}" - LOG.debug(f"Using field.type: {field_id}") - else: - # If no identifier found, generate one based on index - field_id = f"field_{i}" - LOG.debug(f"Using generated id: {field_id}") - - # Create a safe key name - key = str(field_id).lower().replace(" ", "_") - result[key] = field.value - LOG.debug(f"Added field with key '{key}' and value type: {type(field.value).__name__}") - - except Exception as field_err: - LOG.warning(f"Error processing field {i}: {field_err}") - # Still try to capture the value with a generic key - result[f"field_{i}"] = field.value - - # Explicitly look for username and password fields - for i, field in enumerate(item.fields): - try: - # Check for username field using various possible attributes - if "username" not in result: - if hasattr(field, "id") and field.id == "username" and hasattr(field, "value") and field.value: - result["username"] = field.value - LOG.debug(f"Found username field at index {i}") - elif ( - hasattr(field, "purpose") - and field.purpose == "USERNAME" - and hasattr(field, "value") - and field.value - ): - result["username"] = field.value - LOG.debug(f"Found username field by purpose at index {i}") - elif ( - hasattr(field, "type") and field.type == "USERNAME" and hasattr(field, "value") and field.value - ): - result["username"] = field.value - LOG.debug(f"Found username field by type at index {i}") - - # Check for password field using various possible attributes - if "password" not in result: - if hasattr(field, "id") and field.id == "password" and hasattr(field, "value") and field.value: - result["password"] = field.value - LOG.debug(f"Found password field at index {i}") - elif ( - hasattr(field, "purpose") - and field.purpose == "PASSWORD" - and hasattr(field, "value") - and field.value - ): - result["password"] = field.value - LOG.debug(f"Found password field by purpose at index {i}") - elif ( - hasattr(field, "type") and field.type == "PASSWORD" and hasattr(field, "value") and field.value - ): - result["password"] = field.value - LOG.debug(f"Found password field by type at index {i}") - except Exception as field_err: - LOG.warning(f"Error processing username/password field at index {i}: {field_err}") - - # Add TOTP if available - try: - totp = await get_totp_for_item(client, vault_id, item_id) - if totp: - result["totp"] = totp - except Exception as totp_err: - LOG.warning(f"Error getting TOTP: {totp_err}") - - return json.dumps(result) - except Exception as e: - LOG.error(f"Error retrieving 1Password item {vault_id}:{item_id}: {str(e)}") - raise - - -async def get_totp_for_item(client: OnePasswordClient, vault_id: str, item_id: str) -> Optional[str]: - """ - Get the TOTP code for a 1Password item if available. - - Args: - client: Authenticated 1Password client - vault_id: The vault ID - item_id: The item ID - - Returns: - TOTP code if available, None otherwise - """ - try: - totp = await client.items.get_totp(vault_id, item_id) - return totp - except Exception: - # TOTP might not be available for this item - return None diff --git a/skyvern/forge/sdk/workflow/context_manager.py b/skyvern/forge/sdk/workflow/context_manager.py index 06d53e29..bd8865b2 100644 --- a/skyvern/forge/sdk/workflow/context_manager.py +++ b/skyvern/forge/sdk/workflow/context_manager.py @@ -14,6 +14,7 @@ from skyvern.exceptions import ( ) from skyvern.forge import app from skyvern.forge.sdk.api.aws import AsyncAWSClient +from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType from skyvern.forge.sdk.schemas.credentials import PasswordCredential from skyvern.forge.sdk.schemas.organizations import Organization from skyvern.forge.sdk.schemas.tasks import TaskStatus @@ -90,7 +91,9 @@ class WorkflowRunContext: elif isinstance(secrete_parameter, CredentialParameter): await workflow_run_context.register_credential_parameter_value(secrete_parameter, organization) elif isinstance(secrete_parameter, OnePasswordCredentialParameter): - await workflow_run_context.register_onepassword_credential_parameter_value(secrete_parameter) + await workflow_run_context.register_onepassword_credential_parameter_value( + secrete_parameter, organization + ) elif isinstance(secrete_parameter, BitwardenLoginCredentialParameter): await workflow_run_context.register_bitwarden_login_credential_parameter_value( secrete_parameter, organization @@ -313,10 +316,19 @@ class WorkflowRunContext: self.values[parameter.key] = random_secret_id self.parameters[parameter.key] = parameter - async def register_onepassword_credential_parameter_value(self, parameter: OnePasswordCredentialParameter) -> None: + async def register_onepassword_credential_parameter_value( + self, parameter: OnePasswordCredentialParameter, organization: Organization + ) -> None: + org_auth_token = await app.DATABASE.get_valid_org_auth_token( + organization.organization_id, OrganizationAuthTokenType.onepassword_service_account + ) token = settings.OP_SERVICE_ACCOUNT_TOKEN + if org_auth_token: + token = org_auth_token.token if not token: - raise ValueError("OP_SERVICE_ACCOUNT_TOKEN environment variable not set") + raise ValueError( + "OP_SERVICE_ACCOUNT_TOKEN environment variable not set and no valid 1Password service account token found. Please go to the settings and add your 1Password service account token." + ) client = await OnePasswordClient.authenticate( auth=token,