endpoint to get and update onepassword token (#3089)

This commit is contained in:
Shuchang Zheng
2025-08-05 07:34:26 -07:00
committed by GitHub
parent 02576e5be3
commit 00c9446023
9 changed files with 420 additions and 186 deletions

View File

@@ -175,6 +175,24 @@ export type ApiKeyApiResponse = {
valid: boolean; 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 // TODO complete this
export const ActionTypes = { export const ActionTypes = {
InputText: "input_text", InputText: "input_text",

View File

@@ -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<typeof formSchema>;
export function OnePasswordTokenForm() {
const [showToken, setShowToken] = useState(false);
const { onePasswordToken, isLoading, createOrUpdateToken, isUpdating } =
useOnePasswordToken();
const form = useForm<FormData>({
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">
1Password Service Account Token
</h3>
<p className="text-sm text-muted-foreground">
Configure your 1Password service account token for credential
management.
</p>
</div>
{onePasswordToken && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Status:</span>
<span
className={`text-sm ${onePasswordToken.valid ? "text-green-600" : "text-red-600"}`}
>
{onePasswordToken.valid ? "Active" : "Inactive"}
</span>
</div>
)}
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account Token</FormLabel>
<div className="relative">
<FormControl>
<Input
{...field}
type={showToken ? "text" : "password"}
placeholder="op_1234567890abcdef"
disabled={isLoading || isUpdating}
/>
</FormControl>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={toggleTokenVisibility}
disabled={isLoading || isUpdating}
>
{showToken ? (
<EyeClosedIcon className="h-4 w-4" />
) : (
<EyeOpenIcon className="h-4 w-4" />
)}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center gap-4">
<Button type="submit" disabled={isLoading || isUpdating}>
{isUpdating ? "Updating..." : "Update Token"}
</Button>
{onePasswordToken && (
<div className="text-sm text-muted-foreground">
Last updated:{" "}
{new Date(onePasswordToken.modified_at).toLocaleDateString()}
</div>
)}
</div>
</form>
</Form>
{onePasswordToken && (
<div className="rounded-md bg-muted p-4">
<h4 className="mb-2 text-sm font-medium">Token Information</h4>
<div className="space-y-1 text-sm text-muted-foreground">
<div>ID: {onePasswordToken.id}</div>
<div>Type: {onePasswordToken.token_type}</div>
<div>
Created:{" "}
{new Date(onePasswordToken.created_at).toLocaleDateString()}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<OnePasswordTokenApiResponse>({
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,
};
}

View File

@@ -16,6 +16,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { envCredential } from "@/util/env"; import { envCredential } from "@/util/env";
import { HiddenCopyableInput } from "@/components/ui/hidden-copyable-input"; import { HiddenCopyableInput } from "@/components/ui/hidden-copyable-input";
import { OnePasswordTokenForm } from "@/components/OnePasswordTokenForm";
function Settings() { function Settings() {
const { environment, organization, setEnvironment, setOrganization } = const { environment, organization, setEnvironment, setOrganization } =
@@ -67,6 +68,25 @@ function Settings() {
<HiddenCopyableInput value={apiKey ?? "API key not found"} /> <HiddenCopyableInput value={apiKey ?? "API key not found"} />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="border-b-2">
<CardTitle className="text-lg">1Password Integration</CardTitle>
<CardDescription>
Manage your 1Password service account token.{" "}
<a
href="https://developer.1password.com/docs/service-accounts/get-started/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
Learn how to create a service account and get your token.
</a>
</CardDescription>
</CardHeader>
<CardContent className="p-8">
<OnePasswordTokenForm />
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -967,6 +967,29 @@ class AgentDB:
return await convert_to_organization_auth_token(auth_token) 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( async def get_artifacts_for_task_v2(
self, self,
task_v2_id: str, task_v2_id: str,

View File

@@ -3,6 +3,7 @@ from fastapi import Body, Depends, HTTPException, Path, Query
from skyvern.forge import app from skyvern.forge import app
from skyvern.forge.prompts import prompt_engine from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
from skyvern.forge.sdk.routes.code_samples import ( from skyvern.forge.sdk.routes.code_samples import (
CREATE_CREDENTIAL_CODE_SAMPLE, CREATE_CREDENTIAL_CODE_SAMPLE,
CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD, CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD,
@@ -19,7 +20,11 @@ from skyvern.forge.sdk.schemas.credentials import (
CreditCardCredentialResponse, CreditCardCredentialResponse,
PasswordCredentialResponse, 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.schemas.totp_codes import TOTPCode, TOTPCodeCreate
from skyvern.forge.sdk.services import org_auth_service from skyvern.forge.sdk.services import org_auth_service
from skyvern.forge.sdk.services.bitwarden import BitwardenService from skyvern.forge.sdk.services.bitwarden import BitwardenService
@@ -367,3 +372,117 @@ async def get_credentials(
) )
) )
return response_items 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)}",
)

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
@@ -31,6 +31,25 @@ class OrganizationAuthToken(BaseModel):
modified_at: datetime 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): class GetOrganizationsResponse(BaseModel):
organizations: list[Organization] organizations: list[Organization]

View File

@@ -1,11 +1,5 @@
import json
import logging import logging
from enum import StrEnum from enum import StrEnum
from typing import Optional
from onepassword.client import Client as OnePasswordClient
from skyvern.config import settings
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -14,178 +8,3 @@ class OnePasswordConstants(StrEnum):
"""Constants for 1Password integration.""" """Constants for 1Password integration."""
TOTP = "OP_TOTP" # Special value to indicate a TOTP code 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

View File

@@ -14,6 +14,7 @@ from skyvern.exceptions import (
) )
from skyvern.forge import app from skyvern.forge import app
from skyvern.forge.sdk.api.aws import AsyncAWSClient 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.credentials import PasswordCredential
from skyvern.forge.sdk.schemas.organizations import Organization from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.tasks import TaskStatus from skyvern.forge.sdk.schemas.tasks import TaskStatus
@@ -90,7 +91,9 @@ class WorkflowRunContext:
elif isinstance(secrete_parameter, CredentialParameter): elif isinstance(secrete_parameter, CredentialParameter):
await workflow_run_context.register_credential_parameter_value(secrete_parameter, organization) await workflow_run_context.register_credential_parameter_value(secrete_parameter, organization)
elif isinstance(secrete_parameter, OnePasswordCredentialParameter): 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): elif isinstance(secrete_parameter, BitwardenLoginCredentialParameter):
await workflow_run_context.register_bitwarden_login_credential_parameter_value( await workflow_run_context.register_bitwarden_login_credential_parameter_value(
secrete_parameter, organization secrete_parameter, organization
@@ -313,10 +316,19 @@ class WorkflowRunContext:
self.values[parameter.key] = random_secret_id self.values[parameter.key] = random_secret_id
self.parameters[parameter.key] = parameter 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 token = settings.OP_SERVICE_ACCOUNT_TOKEN
if org_auth_token:
token = org_auth_token.token
if not 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( client = await OnePasswordClient.authenticate(
auth=token, auth=token,