2026-02-16 23:43:25 -05:00
""" Credential management API endpoints.
SECURITY INVARIANT — NO RAW CREDENTIAL RETRIEVAL
== == == == == == == == == == == == == == == == == == == == == == == == =
Credential endpoints must NEVER return sensitive credential data ( passwords ,
TOTP secrets , full card numbers , CVVs , expiration dates , card holder names ,
or secret values ) in any API response . The only fields that may be returned
are non - sensitive metadata :
- Password credentials : ` ` username ` ` , ` ` totp_type ` ` , ` ` totp_identifier ` `
- Credit card credentials : ` ` last_four ` ` , ` ` brand ` `
- Secret credentials : ` ` secret_label ` `
This is enforced by the ` ` * CredentialResponse ` ` Pydantic models and the
` ` _convert_to_response ( ) ` ` helper . When adding new credential types or
modifying existing ones , ensure that :
1. The response model never includes the raw secret material .
2. The ` ` _convert_to_response ( ) ` ` function only maps non - sensitive fields .
3. No endpoint ( including ` ` get_credential ` ` and ` ` get_credentials ` ` ) ever
fetches and returns the decrypted secret from the vault .
Violating this invariant would allow any caller with a valid API key to
exfiltrate stored passwords , card numbers , and secrets — which is the
exact threat the vault architecture is designed to prevent .
"""
2026-02-20 02:23:32 +03:00
import asyncio
2025-12-03 06:55:04 +05:30
import json
2026-02-20 02:23:32 +03:00
from datetime import datetime
2025-12-03 06:55:04 +05:30
2025-05-15 19:49:42 -07:00
import structlog
2025-10-08 14:39:15 -06:00
from fastapi import BackgroundTasks , Body , Depends , HTTPException , Path , Query
2025-05-15 19:49:42 -07:00
2025-10-10 10:10:18 -06:00
from skyvern . config import settings
2025-05-15 19:49:42 -07:00
from skyvern . forge import app
2025-08-05 07:34:26 -07:00
from skyvern . forge . sdk . db . enums import OrganizationAuthTokenType
2026-02-20 02:23:32 +03:00
from skyvern . forge . sdk . executor . factory import AsyncExecutorFactory
2025-05-18 13:45:46 -07:00
from skyvern . forge . sdk . routes . code_samples import (
2025-10-28 17:43:19 -06:00
CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_PYTHON ,
CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_TS ,
CREATE_CREDENTIAL_CODE_SAMPLE_PYTHON ,
CREATE_CREDENTIAL_CODE_SAMPLE_TS ,
DELETE_CREDENTIAL_CODE_SAMPLE_PYTHON ,
DELETE_CREDENTIAL_CODE_SAMPLE_TS ,
GET_CREDENTIAL_CODE_SAMPLE_PYTHON ,
GET_CREDENTIAL_CODE_SAMPLE_TS ,
GET_CREDENTIALS_CODE_SAMPLE_PYTHON ,
GET_CREDENTIALS_CODE_SAMPLE_TS ,
SEND_TOTP_CODE_CODE_SAMPLE_PYTHON ,
SEND_TOTP_CODE_CODE_SAMPLE_TS ,
2025-05-18 13:45:46 -07:00
)
2025-05-15 19:49:42 -07:00
from skyvern . forge . sdk . routes . routers import base_router , legacy_base_router
from skyvern . forge . sdk . schemas . credentials import (
2026-02-20 02:23:32 +03:00
CancelTestResponse ,
2025-05-15 19:49:42 -07:00
CreateCredentialRequest ,
2025-11-04 10:29:51 -07:00
Credential ,
2025-05-15 19:49:42 -07:00
CredentialResponse ,
CredentialType ,
2025-10-10 10:10:18 -06:00
CredentialVaultType ,
2025-05-15 19:49:42 -07:00
CreditCardCredentialResponse ,
2026-02-20 02:23:32 +03:00
NonEmptyPasswordCredential ,
2025-05-15 19:49:42 -07:00
PasswordCredentialResponse ,
2025-12-09 11:19:57 -08:00
SecretCredentialResponse ,
2026-02-20 02:23:32 +03:00
TestCredentialRequest ,
TestCredentialResponse ,
TestCredentialStatusResponse ,
TestLoginRequest ,
TestLoginResponse ,
TotpType ,
UpdateCredentialRequest ,
2025-05-15 19:49:42 -07:00
)
2025-08-05 07:34:26 -07:00
from skyvern . forge . sdk . schemas . organizations import (
2025-09-23 10:16:48 -06:00
AzureClientSecretCredentialResponse ,
CreateAzureClientSecretCredentialRequest ,
2025-12-03 06:55:04 +05:30
CreateCustomCredentialServiceConfigRequest ,
2025-08-05 07:34:26 -07:00
CreateOnePasswordTokenRequest ,
CreateOnePasswordTokenResponse ,
2025-12-03 06:55:04 +05:30
CustomCredentialServiceConfigResponse ,
2025-08-05 07:34:26 -07:00
Organization ,
)
2025-10-14 16:24:14 +08:00
from skyvern . forge . sdk . schemas . totp_codes import OTPType , TOTPCode , TOTPCodeCreate
2025-05-15 19:49:42 -07:00
from skyvern . forge . sdk . services import org_auth_service
from skyvern . forge . sdk . services . bitwarden import BitwardenService
2025-10-10 10:10:18 -06:00
from skyvern . forge . sdk . services . credential . credential_vault_service import CredentialVaultService
2026-02-20 02:23:32 +03:00
from skyvern . forge . sdk . workflow . models . parameter import WorkflowParameterType
from skyvern . forge . sdk . workflow . models . workflow import WorkflowRequestBody , WorkflowRunStatus
from skyvern . schemas . workflows import (
LoginBlockYAML ,
WorkflowCreateYAMLRequest ,
WorkflowDefinitionYAML ,
WorkflowParameterYAML ,
WorkflowStatus ,
)
2025-10-14 16:24:14 +08:00
from skyvern . services . otp_service import OTPValue , parse_otp_login
2026-02-20 02:23:32 +03:00
from skyvern . services . run_service import cancel_workflow_run
2025-05-15 19:49:42 -07:00
LOG = structlog . get_logger ( )
2026-02-20 02:23:32 +03:00
# Strong references to background tasks to prevent GC before completion.
# See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
_background_tasks : set [ asyncio . Task ] = set ( )
2025-05-15 19:49:42 -07:00
2025-10-08 14:39:15 -06:00
async def fetch_credential_item_background ( item_id : str ) - > None :
"""
Background task to fetch the recently added credential item from Bitwarden .
This triggers Bitwarden to sync the vault earlier so the next request does not have to wait for the sync .
"""
try :
LOG . info ( " Pre-fetching credential item from Bitwarden in background " , item_id = item_id )
credential_item = await BitwardenService . get_credential_item ( item_id )
LOG . info ( " Successfully fetched credential item from Bitwarden " , item_id = item_id , name = credential_item . name )
except Exception as e :
LOG . exception ( " Failed to fetch credential item from Bitwarden in background " , item_id = item_id , error = str ( e ) )
2025-07-15 02:01:23 -07:00
@legacy_base_router.post ( " /totp " )
2025-05-15 19:49:42 -07:00
@legacy_base_router.post ( " /totp/ " , include_in_schema = False )
@base_router.post (
" /credentials/totp " ,
response_model = TOTPCode ,
2025-07-15 02:05:33 -07:00
summary = " Send TOTP code " ,
2025-05-21 20:06:34 -07:00
description = " Forward a TOTP (2FA, MFA) email or sms message containing the code to Skyvern. This endpoint stores the code in database so that Skyvern can use it while running tasks/workflows. " ,
2025-05-15 19:49:42 -07:00
tags = [ " Credentials " ] ,
openapi_extra = {
" x-fern-sdk-method-name " : " send_totp_code " ,
2025-10-28 17:43:19 -06:00
" x-fern-examples " : [
{
" code-samples " : [
{ " sdk " : " python " , " code " : SEND_TOTP_CODE_CODE_SAMPLE_PYTHON } ,
{ " sdk " : " typescript " , " code " : SEND_TOTP_CODE_CODE_SAMPLE_TS } ,
]
}
] ,
2025-05-15 19:49:42 -07:00
} ,
)
2025-05-18 13:45:46 -07:00
@base_router.post (
" /credentials/totp/ " ,
response_model = TOTPCode ,
include_in_schema = False ,
)
2025-05-15 19:49:42 -07:00
async def send_totp_code (
2025-09-11 18:10:05 -07:00
data : TOTPCodeCreate ,
curr_org : Organization = Depends ( org_auth_service . get_current_org ) ,
2025-05-15 19:49:42 -07:00
) - > TOTPCode :
LOG . info (
2025-10-15 01:28:42 +08:00
" Saving OTP code " ,
2025-05-15 19:49:42 -07:00
organization_id = curr_org . organization_id ,
totp_identifier = data . totp_identifier ,
task_id = data . task_id ,
workflow_id = data . workflow_id ,
2025-07-17 21:36:18 -07:00
workflow_run_id = data . workflow_run_id ,
2025-05-15 19:49:42 -07:00
)
2025-11-16 07:10:49 -08:00
# validate task_id, workflow_id, workflow_run_id are valid ids in db if provided
if data . task_id :
task = await app . DATABASE . get_task ( data . task_id , curr_org . organization_id )
if not task :
raise HTTPException ( status_code = 400 , detail = f " Invalid task id: { data . task_id } " )
2026-02-18 17:21:58 -05:00
workflow_id_for_storage : str | None = None
2025-11-16 07:10:49 -08:00
if data . workflow_id :
2026-02-18 17:21:58 -05:00
if data . workflow_id . startswith ( " wpid_ " ) :
workflow = await app . DATABASE . get_workflow_by_permanent_id ( data . workflow_id , curr_org . organization_id )
else :
workflow = await app . DATABASE . get_workflow ( data . workflow_id , curr_org . organization_id )
2025-11-16 07:10:49 -08:00
if not workflow :
raise HTTPException ( status_code = 400 , detail = f " Invalid workflow id: { data . workflow_id } " )
2026-02-18 17:21:58 -05:00
workflow_id_for_storage = workflow . workflow_id
2025-11-16 07:10:49 -08:00
if data . workflow_run_id :
workflow_run = await app . DATABASE . get_workflow_run ( data . workflow_run_id , curr_org . organization_id )
if not workflow_run :
raise HTTPException ( status_code = 400 , detail = f " Invalid workflow run id: { data . workflow_run_id } " )
2025-05-26 13:28:20 -07:00
content = data . content . strip ( )
2026-01-23 00:39:07 -08:00
otp_value : OTPValue | None = OTPValue ( value = content , type = data . type or OTPType . TOTP )
2025-05-26 13:28:20 -07:00
# We assume the user is sending the code directly when the length of code is less than or equal to 10
if len ( content ) > 10 :
2026-01-23 00:39:07 -08:00
otp_value = await parse_otp_login ( content , curr_org . organization_id , enforced_otp_type = data . type )
2025-10-15 01:28:42 +08:00
if not otp_value :
2025-05-26 13:28:20 -07:00
LOG . error (
2025-10-15 01:28:42 +08:00
" Failed to parse otp login " ,
2025-05-26 13:28:20 -07:00
totp_identifier = data . totp_identifier ,
task_id = data . task_id ,
workflow_id = data . workflow_id ,
workflow_run_id = data . workflow_run_id ,
content = data . content ,
)
2025-10-15 01:28:42 +08:00
raise HTTPException ( status_code = 400 , detail = " Failed to parse otp login " )
2025-10-14 16:24:14 +08:00
return await app . DATABASE . create_otp_code (
2025-05-15 19:49:42 -07:00
organization_id = curr_org . organization_id ,
totp_identifier = data . totp_identifier ,
content = data . content ,
2025-10-15 01:28:42 +08:00
code = otp_value . value ,
2025-05-15 19:49:42 -07:00
task_id = data . task_id ,
2026-02-18 17:21:58 -05:00
workflow_id = workflow_id_for_storage ,
2025-05-15 19:49:42 -07:00
workflow_run_id = data . workflow_run_id ,
source = data . source ,
expired_at = data . expired_at ,
2025-10-15 01:28:42 +08:00
otp_type = otp_value . get_otp_type ( ) ,
2025-05-15 19:49:42 -07:00
)
2025-10-29 20:49:25 -07:00
@base_router.get (
" /credentials/totp " ,
response_model = list [ TOTPCode ] ,
summary = " List TOTP codes " ,
description = " Retrieves recent TOTP codes for the current organization. " ,
tags = [ " Credentials " ] ,
openapi_extra = {
" x-fern-sdk-method-name " : " get_totp_codes " ,
} ,
include_in_schema = False ,
)
@base_router.get (
" /credentials/totp/ " ,
response_model = list [ TOTPCode ] ,
include_in_schema = False ,
)
async def get_totp_codes (
curr_org : Organization = Depends ( org_auth_service . get_current_org ) ,
totp_identifier : str | None = Query (
None ,
description = " Filter by TOTP identifier such as an email or phone number. " ,
examples = [ " john.doe@example.com " ] ,
) ,
workflow_run_id : str | None = Query (
None ,
description = " Filter by workflow run ID. " ,
examples = [ " wr_123456 " ] ,
) ,
otp_type : OTPType | None = Query (
None ,
description = " Filter by OTP type (e.g. totp, magic_link). " ,
examples = [ OTPType . TOTP . value ] ,
) ,
limit : int = Query (
50 ,
ge = 1 ,
le = 200 ,
description = " Maximum number of codes to return. " ,
) ,
) - > list [ TOTPCode ] :
2025-11-24 10:04:14 -08:00
codes = await app . DATABASE . get_recent_otp_codes (
organization_id = curr_org . organization_id ,
limit = limit ,
valid_lifespan_minutes = None ,
otp_type = otp_type ,
workflow_run_id = workflow_run_id ,
totp_identifier = totp_identifier ,
)
2025-10-29 20:49:25 -07:00
return codes
2025-05-18 12:43:22 -07:00
@legacy_base_router.post ( " /credentials " )
@legacy_base_router.post ( " /credentials/ " , include_in_schema = False )
@base_router.post (
2025-05-15 19:49:42 -07:00
" /credentials " ,
response_model = CredentialResponse ,
2025-05-18 12:43:22 -07:00
status_code = 201 ,
summary = " Create credential " ,
description = " Creates a new credential for the current organization " ,
2025-05-15 19:49:42 -07:00
tags = [ " Credentials " ] ,
openapi_extra = {
2025-05-18 12:43:22 -07:00
" x-fern-sdk-method-name " : " create_credential " ,
2025-05-18 13:45:46 -07:00
" x-fern-examples " : [
{
" code-samples " : [
2025-10-28 17:43:19 -06:00
{ " sdk " : " python " , " code " : CREATE_CREDENTIAL_CODE_SAMPLE_PYTHON } ,
{ " sdk " : " python " , " code " : CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_PYTHON } ,
{ " sdk " : " typescript " , " code " : CREATE_CREDENTIAL_CODE_SAMPLE_TS } ,
{ " sdk " : " typescript " , " code " : CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_TS } ,
2025-05-18 13:45:46 -07:00
]
}
] ,
2025-05-15 19:49:42 -07:00
} ,
)
2025-05-18 13:45:46 -07:00
@base_router.post (
" /credentials/ " ,
response_model = CredentialResponse ,
status_code = 201 ,
include_in_schema = False ,
)
2025-05-18 12:43:22 -07:00
async def create_credential (
2025-10-08 14:39:15 -06:00
background_tasks : BackgroundTasks ,
2025-05-18 12:43:22 -07:00
data : CreateCredentialRequest = Body (
2025-05-15 19:49:42 -07:00
. . . ,
2025-05-18 12:43:22 -07:00
description = " The credential data to create " ,
example = {
" name " : " My Credential " ,
" credential_type " : " PASSWORD " ,
" credential " : { " username " : " user@example.com " , " password " : " securepassword123 " , " totp " : " JBSWY3DPEHPK3PXP " } ,
} ,
openapi_extra = { " x-fern-sdk-parameter-name " : " data " } ,
2025-05-15 19:49:42 -07:00
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CredentialResponse :
2025-11-04 10:29:51 -07:00
credential_service = await _get_credential_vault_service ( )
2025-10-10 10:10:18 -06:00
credential = await credential_service . create_credential ( organization_id = current_org . organization_id , data = data )
2025-05-15 19:49:42 -07:00
2025-10-10 10:10:18 -06:00
if credential . vault_type == CredentialVaultType . BITWARDEN :
# Early resyncing the Bitwarden vault
background_tasks . add_task ( fetch_credential_item_background , credential . item_id )
2025-10-08 14:39:15 -06:00
2025-05-18 12:43:22 -07:00
if data . credential_type == CredentialType . PASSWORD :
2025-05-15 19:49:42 -07:00
credential_response = PasswordCredentialResponse (
2025-05-18 12:43:22 -07:00
username = data . credential . username ,
2025-10-08 11:38:34 -07:00
totp_type = data . credential . totp_type if hasattr ( data . credential , " totp_type " ) else " none " ,
2025-05-15 19:49:42 -07:00
)
return CredentialResponse (
credential = credential_response ,
credential_id = credential . credential_id ,
2025-05-18 12:43:22 -07:00
credential_type = data . credential_type ,
name = data . name ,
2025-05-15 19:49:42 -07:00
)
2025-05-18 12:43:22 -07:00
elif data . credential_type == CredentialType . CREDIT_CARD :
2025-05-15 19:49:42 -07:00
credential_response = CreditCardCredentialResponse (
2025-05-18 12:43:22 -07:00
last_four = data . credential . card_number [ - 4 : ] ,
brand = data . credential . card_brand ,
2025-05-15 19:49:42 -07:00
)
return CredentialResponse (
credential = credential_response ,
credential_id = credential . credential_id ,
2025-05-18 12:43:22 -07:00
credential_type = data . credential_type ,
name = data . name ,
2025-05-15 19:49:42 -07:00
)
2025-12-09 11:19:57 -08:00
elif data . credential_type == CredentialType . SECRET :
credential_response = SecretCredentialResponse ( secret_label = data . credential . secret_label )
return CredentialResponse (
credential = credential_response ,
credential_id = credential . credential_id ,
credential_type = data . credential_type ,
name = data . name ,
)
2025-10-09 11:39:01 -06:00
else :
raise HTTPException ( status_code = 400 , detail = f " Unsupported credential type: { data . credential_type } " )
2025-05-15 19:49:42 -07:00
2026-02-20 02:23:32 +03:00
DEFAULT_LOGIN_PROMPT = (
" Navigate to the login page if needed and log in with the provided credentials. "
" Fill in the username and password fields and submit the form. "
" After submitting, verify whether the login was successful by checking the page content. "
" IMPORTANT: If the page asks for a credential you were NOT provided (e.g., a phone number, "
" security question, or any field you don ' t have a value for), TERMINATE IMMEDIATELY and "
" report that the login requires additional information that was not provided. "
" Do NOT guess, make up values, or re-use other credentials in the wrong field. "
" CRITICAL RULE — YOU MUST FOLLOW THIS: You may only submit the login form ONCE. "
" After submitting, if the website shows ANY error or rejection — such as ' wrong password ' , "
" ' invalid credentials ' , ' incorrect password ' , ' account locked ' , ' suspended ' , "
" ' too many attempts ' , or any other error message — you MUST TERMINATE IMMEDIATELY. "
" Do NOT fill in the form again. Do NOT click submit again. Do NOT retry. "
" A failed login cannot be fixed by retrying with the same credentials. "
" Retrying will cause the account to be locked or suspended. "
" Report the exact error message from the website and terminate. "
)
BROWSER_PROFILE_LOGIN_PROMPT = (
" A browser profile with saved session data has been loaded. "
" FIRST, check whether you are already logged in by examining the page content. "
" Look for signs of an authenticated session such as a dashboard, welcome message, "
" user menu, profile icon, or any content that indicates a logged-in state. "
" If you are already logged in, report success immediately — do NOT interact with "
" any form fields or attempt to log in again. "
" Only if the page clearly shows a login form and you are NOT logged in, "
" then log in with the provided credentials. Fill in the username and password fields "
" and submit the form. After submitting, verify whether the login was successful. "
" IMPORTANT: If the page asks for a credential you were NOT provided (e.g., a phone number, "
" security question, or any field you don ' t have a value for), TERMINATE IMMEDIATELY and "
" report that the login requires additional information that was not provided. "
" Do NOT guess, make up values, or re-use other credentials in the wrong field. "
" CRITICAL RULE — YOU MUST FOLLOW THIS: You may only submit the login form ONCE. "
" After submitting, if the website shows ANY error or rejection — such as ' wrong password ' , "
" ' invalid credentials ' , ' incorrect password ' , ' account locked ' , ' suspended ' , "
" ' too many attempts ' , or any other error message — you MUST TERMINATE IMMEDIATELY. "
" Do NOT fill in the form again. Do NOT click submit again. Do NOT retry. "
" A failed login cannot be fixed by retrying with the same credentials. "
" Retrying will cause the account to be locked or suspended. "
" Report the exact error message from the website and terminate. "
)
LOGIN_TEST_TERMINATE_CRITERION = (
" Terminate IMMEDIATELY if ANY of these conditions are true: "
" (1) The website displays an error message after a login attempt (e.g., wrong password, "
" invalid credentials, account locked, suspicious activity, too many attempts). "
" (2) The page asks for information you were not provided (e.g., phone number, "
" security question, verification code that isn ' t TOTP). "
" (3) You have already submitted the login form once and it was not successful. "
" Never attempt to log in more than once. Never re-enter credentials after a failed attempt. "
)
@base_router.patch (
" /credentials/ {credential_id} " ,
response_model = CredentialResponse ,
summary = " Rename credential " ,
description = " Updates a credential ' s metadata (e.g. name) without changing the stored secret. " ,
tags = [ " Credentials " ] ,
include_in_schema = False ,
)
@base_router.patch (
" /credentials/ {credential_id} / " ,
response_model = CredentialResponse ,
include_in_schema = False ,
)
async def rename_credential (
credential_id : str = Path (
. . . ,
description = " The unique identifier of the credential to update " ,
examples = [ " cred_1234567890 " ] ,
) ,
data : UpdateCredentialRequest = Body (
. . . ,
description = " The credential fields to update " ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CredentialResponse :
credential = await app . DATABASE . get_credential (
credential_id = credential_id , organization_id = current_org . organization_id
)
if not credential :
raise HTTPException ( status_code = 404 , detail = f " Credential not found, credential_id= { credential_id } " )
update_kwargs : dict = {
" credential_id " : credential_id ,
" organization_id " : current_org . organization_id ,
" name " : data . name ,
}
if data . tested_url is not None :
update_kwargs [ " tested_url " ] = data . tested_url
updated = await app . DATABASE . update_credential ( * * update_kwargs )
if not updated :
raise HTTPException ( status_code = 500 , detail = " Failed to update credential " )
return _convert_to_response ( updated )
@base_router.post (
" /credentials/test-login " ,
response_model = TestLoginResponse ,
summary = " Test login with inline credentials " ,
description = (
" Test a login by providing credentials inline (no saved credential required). "
" Creates a temporary credential, runs a login test, and returns a workflow run ID to poll. "
) ,
tags = [ " Credentials " ] ,
include_in_schema = False ,
)
@base_router.post (
" /credentials/test-login/ " ,
response_model = TestLoginResponse ,
include_in_schema = False ,
)
async def test_login (
background_tasks : BackgroundTasks ,
data : TestLoginRequest = Body (
. . . ,
description = " The login credentials and URL to test " ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > TestLoginResponse :
""" Test a login with inline credentials without requiring a saved credential. """
organization_id = current_org . organization_id
# Create a temporary credential
create_request = CreateCredentialRequest (
name = f " _test_login_ { data . username } " ,
credential_type = CredentialType . PASSWORD ,
credential = NonEmptyPasswordCredential (
username = data . username ,
password = data . password ,
totp = data . totp ,
totp_type = data . totp_type ,
totp_identifier = data . totp_identifier ,
) ,
)
credential_service = await _get_credential_vault_service ( )
credential = await credential_service . create_credential (
organization_id = organization_id ,
data = create_request ,
)
if credential . vault_type == CredentialVaultType . BITWARDEN :
background_tasks . add_task ( fetch_credential_item_background , credential . item_id )
credential_id = credential . credential_id
LOG . info (
" Testing login with inline credentials " ,
credential_id = credential_id ,
organization_id = organization_id ,
url = data . url ,
)
# Build a login workflow
parameter_key = " credential "
label = " login "
yaml_parameters = [
WorkflowParameterYAML (
key = parameter_key ,
workflow_parameter_type = WorkflowParameterType . CREDENTIAL_ID ,
description = " The credential to test " ,
default_value = credential_id ,
)
]
# 2FA flows need more steps (enter code, submit) than plain password logins
max_steps = 5 if data . totp_type != TotpType . NONE else 3
login_block_yaml = LoginBlockYAML (
label = label ,
title = label ,
url = data . url ,
navigation_goal = DEFAULT_LOGIN_PROMPT ,
terminate_criterion = LOGIN_TEST_TERMINATE_CRITERION ,
max_steps_per_run = max_steps ,
parameter_keys = [ parameter_key ] ,
totp_verification_url = None ,
totp_identifier = data . totp_identifier ,
)
workflow_definition_yaml = WorkflowDefinitionYAML (
parameters = yaml_parameters ,
blocks = [ login_block_yaml ] ,
)
workflow_create_request = WorkflowCreateYAMLRequest (
title = f " Login Test - { data . username } " ,
description = " Auto-generated workflow to test login credentials " ,
persist_browser_session = True ,
workflow_definition = workflow_definition_yaml ,
status = WorkflowStatus . auto_generated ,
)
try :
workflow = await app . WORKFLOW_SERVICE . create_workflow_from_request (
organization = current_org ,
request = workflow_create_request ,
)
run_request = WorkflowRequestBody ( )
workflow_run = await app . WORKFLOW_SERVICE . setup_workflow_run (
request_id = None ,
workflow_request = run_request ,
workflow_permanent_id = workflow . workflow_permanent_id ,
organization = current_org ,
max_steps_override = None ,
)
await AsyncExecutorFactory . get_executor ( ) . execute_workflow (
request = None ,
background_tasks = background_tasks ,
organization = current_org ,
workflow_id = workflow_run . workflow_id ,
workflow_run_id = workflow_run . workflow_run_id ,
workflow_permanent_id = workflow_run . workflow_permanent_id ,
max_steps_override = None ,
api_key = None ,
browser_session_id = None ,
block_labels = None ,
block_outputs = None ,
)
except Exception :
# Clean up the orphaned temporary credential if workflow setup fails
LOG . exception (
" Workflow setup failed for test_login, cleaning up temporary credential " ,
credential_id = credential_id ,
organization_id = organization_id ,
)
try :
await app . DATABASE . delete_credential (
credential_id = credential_id ,
organization_id = organization_id ,
)
except Exception :
LOG . warning (
" Failed to clean up temporary credential after workflow setup error " ,
credential_id = credential_id ,
exc_info = True ,
)
raise
# Always schedule profile creation for test_login — the entire purpose of this
# endpoint is to create a temporary credential with a browser profile. This differs
# from test_credential, which conditionally checks data.save_browser_profile because
# that endpoint tests an existing credential that may or may not need a profile.
task = asyncio . create_task (
_create_browser_profile_after_workflow (
credential_id = credential_id ,
workflow_run_id = workflow_run . workflow_run_id ,
workflow_id = workflow_run . workflow_id ,
workflow_permanent_id = workflow . workflow_permanent_id ,
organization_id = organization_id ,
credential_name = f " _test_login_ { data . username } " ,
test_url = data . url ,
)
)
_background_tasks . add ( task )
task . add_done_callback ( _background_tasks . discard )
LOG . info (
" Login test started " ,
credential_id = credential_id ,
workflow_run_id = workflow_run . workflow_run_id ,
organization_id = organization_id ,
)
return TestLoginResponse (
credential_id = credential_id ,
workflow_run_id = workflow_run . workflow_run_id ,
status = " running " ,
)
@base_router.post (
" /credentials/ {credential_id} /test " ,
response_model = TestCredentialResponse ,
summary = " Test a credential " ,
description = (
" Test a credential by running a login task against the specified URL. "
" Optionally saves the browser profile after a successful login for reuse in workflows. "
) ,
tags = [ " Credentials " ] ,
include_in_schema = False ,
)
@base_router.post (
" /credentials/ {credential_id} /test/ " ,
response_model = TestCredentialResponse ,
include_in_schema = False ,
)
async def test_credential (
background_tasks : BackgroundTasks ,
credential_id : str = Path (
. . . ,
description = " The credential ID to test " ,
examples = [ " cred_1234567890 " ] ,
) ,
data : TestCredentialRequest = Body (
. . . ,
description = " Test configuration including the login URL " ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > TestCredentialResponse :
organization_id = current_org . organization_id
# Validate credential exists and is a password type
credential = await app . DATABASE . get_credential ( credential_id = credential_id , organization_id = organization_id )
if not credential :
raise HTTPException ( status_code = 404 , detail = f " Credential { credential_id } not found " )
if credential . credential_type != CredentialType . PASSWORD :
raise HTTPException (
status_code = 400 ,
detail = " Only password credentials can be tested with login " ,
)
# Check if the credential already has a browser profile
existing_browser_profile_id = credential . browser_profile_id
if existing_browser_profile_id :
profile = await app . DATABASE . get_browser_profile (
profile_id = existing_browser_profile_id ,
organization_id = organization_id ,
)
if not profile :
LOG . warning (
" Credential has browser_profile_id but profile not found, ignoring " ,
credential_id = credential_id ,
browser_profile_id = existing_browser_profile_id ,
)
existing_browser_profile_id = None
LOG . info (
" Testing credential " ,
credential_id = credential_id ,
organization_id = organization_id ,
url = data . url ,
save_browser_profile = data . save_browser_profile ,
existing_browser_profile_id = existing_browser_profile_id ,
)
navigation_goal = BROWSER_PROFILE_LOGIN_PROMPT if existing_browser_profile_id else DEFAULT_LOGIN_PROMPT
parameter_key = " credential "
label = " login "
yaml_parameters = [
WorkflowParameterYAML (
key = parameter_key ,
workflow_parameter_type = WorkflowParameterType . CREDENTIAL_ID ,
description = " The credential to test " ,
default_value = credential_id ,
)
]
# 2FA flows need more steps (enter code, submit) than plain password logins
max_steps = 5 if credential . totp_type != TotpType . NONE else 3
login_block_yaml = LoginBlockYAML (
label = label ,
title = label ,
url = data . url ,
navigation_goal = navigation_goal ,
terminate_criterion = LOGIN_TEST_TERMINATE_CRITERION ,
max_steps_per_run = max_steps ,
parameter_keys = [ parameter_key ] ,
totp_verification_url = None ,
totp_identifier = credential . totp_identifier ,
)
workflow_definition_yaml = WorkflowDefinitionYAML (
parameters = yaml_parameters ,
blocks = [ login_block_yaml ] ,
)
workflow_create_request = WorkflowCreateYAMLRequest (
title = f " Credential Test - { credential . name } " ,
description = f " Auto-generated workflow to test credential { credential_id } " ,
persist_browser_session = data . save_browser_profile ,
workflow_definition = workflow_definition_yaml ,
status = WorkflowStatus . auto_generated ,
)
try :
workflow = await app . WORKFLOW_SERVICE . create_workflow_from_request (
organization = current_org ,
request = workflow_create_request ,
)
run_request = WorkflowRequestBody (
browser_profile_id = existing_browser_profile_id ,
)
workflow_run = await app . WORKFLOW_SERVICE . setup_workflow_run (
request_id = None ,
workflow_request = run_request ,
workflow_permanent_id = workflow . workflow_permanent_id ,
organization = current_org ,
max_steps_override = None ,
)
await AsyncExecutorFactory . get_executor ( ) . execute_workflow (
request = None ,
background_tasks = background_tasks ,
organization = current_org ,
workflow_id = workflow_run . workflow_id ,
workflow_run_id = workflow_run . workflow_run_id ,
workflow_permanent_id = workflow_run . workflow_permanent_id ,
max_steps_override = None ,
api_key = None ,
browser_session_id = None ,
block_labels = None ,
block_outputs = None ,
)
except Exception :
LOG . exception (
" Workflow setup failed for test_credential " ,
credential_id = credential_id ,
organization_id = organization_id ,
)
raise
if data . save_browser_profile :
task = asyncio . create_task (
_create_browser_profile_after_workflow (
credential_id = credential_id ,
workflow_run_id = workflow_run . workflow_run_id ,
workflow_id = workflow_run . workflow_id ,
workflow_permanent_id = workflow . workflow_permanent_id ,
organization_id = organization_id ,
credential_name = credential . name ,
test_url = data . url ,
)
)
_background_tasks . add ( task )
task . add_done_callback ( _background_tasks . discard )
LOG . info (
" Credential test started " ,
credential_id = credential_id ,
workflow_run_id = workflow_run . workflow_run_id ,
organization_id = organization_id ,
)
return TestCredentialResponse (
credential_id = credential_id ,
workflow_run_id = workflow_run . workflow_run_id ,
status = " running " ,
)
def _humanize_test_failure ( raw_reason : str | None ) - > str :
""" Convert raw workflow failure output into a user-friendly message.
The raw failure_reason from the workflow engine contains LLM output with
element IDs , action types , and technical details that are meaningless to
end users . This function extracts the key insight and returns a concise ,
actionable message .
"""
if not raw_reason :
return " The login test failed. The credentials may be incorrect or the login page may have changed. "
reason_lower = raw_reason . lower ( )
# Log the raw reason for debugging, return friendly message
LOG . debug ( " Raw test failure reason " , raw_reason = raw_reason )
if " reached the maximum steps " in reason_lower :
if " password " in reason_lower :
return (
" Login could not be completed — the password may be incorrect "
" or the login page requires additional steps that couldn ' t be automated. "
)
if " 2fa " in reason_lower or " totp " in reason_lower or " verification " in reason_lower :
return (
" Login could not be completed — the two-factor authentication step "
" could not be automated. Please check your 2FA settings. "
)
return (
" Login could not be completed within the allowed steps. "
" The login page may require additional steps or the credentials may be incorrect. "
)
if " timed out " in reason_lower or " timeout " in reason_lower :
return " The login page took too long to respond. Please check the URL and try again. "
if " navigation " in reason_lower and ( " failed " in reason_lower or " error " in reason_lower ) :
return " Could not navigate to the login page. Please check the URL and try again. "
if " password " in reason_lower and (
" incorrect " in reason_lower or " invalid " in reason_lower or " wrong " in reason_lower
) :
return " The login failed — the password appears to be incorrect. "
if " username " in reason_lower and ( " not found " in reason_lower or " invalid " in reason_lower ) :
return " The login failed — the username was not recognized. "
# Generic fallback — strip technical details
return " The login test was unsuccessful. Please verify your credentials and the login URL, then try again. "
@base_router.get (
" /credentials/ {credential_id} /test/ {workflow_run_id} " ,
response_model = TestCredentialStatusResponse ,
summary = " Get credential test status " ,
description = (
" Poll the status of a credential test. When the test completes successfully "
" and save_browser_profile was enabled, a browser profile will be automatically "
" created and linked to the credential. "
) ,
tags = [ " Credentials " ] ,
include_in_schema = False ,
)
@base_router.get (
" /credentials/ {credential_id} /test/ {workflow_run_id} / " ,
response_model = TestCredentialStatusResponse ,
include_in_schema = False ,
)
async def get_test_credential_status (
credential_id : str = Path (
. . . ,
description = " The credential ID being tested " ,
examples = [ " cred_1234567890 " ] ,
) ,
workflow_run_id : str = Path (
. . . ,
description = " The workflow run ID from the test initiation " ,
examples = [ " wr_1234567890 " ] ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > TestCredentialStatusResponse :
organization_id = current_org . organization_id
workflow_run = await app . DATABASE . get_workflow_run ( workflow_run_id = workflow_run_id , organization_id = organization_id )
if not workflow_run :
raise HTTPException ( status_code = 404 , detail = f " Workflow run { workflow_run_id } not found " )
credential = await app . DATABASE . get_credential ( credential_id = credential_id , organization_id = organization_id )
status = workflow_run . status
status_str = str ( status )
browser_profile_id = credential . browser_profile_id if credential else None
tested_url = credential . tested_url if credential else None
browser_profile_failure_reason : str | None = None
_FAILURE_STATUSES = {
WorkflowRunStatus . failed ,
WorkflowRunStatus . terminated ,
WorkflowRunStatus . timed_out ,
WorkflowRunStatus . canceled ,
}
# If the credential was deleted (temp credential cleaned up after failure),
# derive the status from the workflow run alone.
if not credential and status in _FAILURE_STATUSES :
return TestCredentialStatusResponse (
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
status = status_str ,
failure_reason = _humanize_test_failure ( workflow_run . failure_reason ) ,
browser_profile_id = None ,
tested_url = None ,
browser_profile_failure_reason = None ,
)
elif not credential :
raise HTTPException ( status_code = 404 , detail = f " Credential { credential_id } not found " )
failure_reason : str | None = None
if status == WorkflowRunStatus . failed :
failure_reason = _humanize_test_failure ( workflow_run . failure_reason )
elif status == WorkflowRunStatus . timed_out :
failure_reason = " The login page took too long to respond. Please check the URL and try again. "
elif status == WorkflowRunStatus . terminated :
failure_reason = " The login test was terminated before it could complete. "
elif status == WorkflowRunStatus . canceled :
failure_reason = " The login test was canceled. "
# Detect browser profile creation failure: workflow completed successfully
# but no profile was linked after the background task had time to finish.
# The background task retries session retrieval 5 times with 2s sleeps (~12s),
# so 30s is a generous grace period.
_PROFILE_GRACE_PERIOD_SECONDS = 30
if (
status == WorkflowRunStatus . completed
and not browser_profile_id
and workflow_run . finished_at
and ( datetime . utcnow ( ) - workflow_run . finished_at ) . total_seconds ( ) > _PROFILE_GRACE_PERIOD_SECONDS
) :
browser_profile_failure_reason = (
" Login succeeded but the browser profile could not be saved. Please try testing again. "
)
return TestCredentialStatusResponse (
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
status = status_str ,
failure_reason = failure_reason ,
browser_profile_id = browser_profile_id ,
tested_url = tested_url ,
browser_profile_failure_reason = browser_profile_failure_reason ,
)
@base_router.post (
" /credentials/ {credential_id} /test/ {workflow_run_id} /cancel " ,
response_model = CancelTestResponse ,
summary = " Cancel a credential test " ,
description = " Cancel a running credential test and clean up temporary resources. " ,
tags = [ " Credentials " ] ,
include_in_schema = False ,
)
@base_router.post (
" /credentials/ {credential_id} /test/ {workflow_run_id} /cancel/ " ,
response_model = CancelTestResponse ,
include_in_schema = False ,
)
async def cancel_credential_test (
credential_id : str = Path (
. . . ,
description = " The credential ID being tested " ,
examples = [ " cred_1234567890 " ] ,
) ,
workflow_run_id : str = Path (
. . . ,
description = " The workflow run ID to cancel " ,
examples = [ " wr_1234567890 " ] ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CancelTestResponse :
organization_id = current_org . organization_id
LOG . info (
" Canceling credential test " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
organization_id = organization_id ,
)
try :
await cancel_workflow_run ( workflow_run_id = workflow_run_id , organization_id = organization_id )
except Exception :
LOG . warning (
" Failed to cancel workflow run for credential test " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
exc_info = True ,
)
# Don't clean up the credential or claim success — the workflow may still be running.
# The background task will handle cleanup when the workflow eventually terminates.
return CancelTestResponse ( status = " cancel_failed " )
# Only clean up temporary credentials after successful cancellation.
# The background task may also try to delete — that's fine, it handles NotFound gracefully.
try :
credential = await app . DATABASE . get_credential (
credential_id = credential_id ,
organization_id = organization_id ,
)
if credential and credential . name . startswith ( " _test_login_ " ) :
await app . DATABASE . delete_credential (
credential_id = credential_id ,
organization_id = organization_id ,
)
LOG . info (
" Cleaned up temporary credential after test cancellation " ,
credential_id = credential_id ,
organization_id = organization_id ,
)
except Exception :
LOG . warning (
" Failed to clean up temporary credential after test cancellation " ,
credential_id = credential_id ,
exc_info = True ,
)
return CancelTestResponse ( status = " canceled " )
async def _create_browser_profile_after_workflow (
credential_id : str ,
workflow_run_id : str ,
workflow_id : str ,
workflow_permanent_id : str ,
organization_id : str ,
credential_name : str ,
test_url : str ,
) - > None :
""" Background task that polls the workflow run status and creates a browser
profile from the persisted session when the run completes successfully . """
max_polls = 120 # ~10 minutes at 5s intervals
poll_interval = 5
try :
for _ in range ( max_polls ) :
workflow_run = await app . DATABASE . get_workflow_run (
workflow_run_id = workflow_run_id , organization_id = organization_id
)
if not workflow_run :
LOG . warning (
" Workflow run not found during browser profile creation poll " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
)
return
status = workflow_run . status
if not status . is_final ( ) :
await asyncio . sleep ( poll_interval )
continue
if status != WorkflowRunStatus . completed :
LOG . info (
" Workflow run did not complete successfully, skipping browser profile creation " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
status = status ,
)
# Clean up temporary credentials created by test-login
if credential_name . startswith ( " _test_login_ " ) :
try :
await app . DATABASE . delete_credential (
credential_id = credential_id ,
organization_id = organization_id ,
)
LOG . info (
" Deleted temporary credential after failed test " ,
credential_id = credential_id ,
organization_id = organization_id ,
)
except Exception :
LOG . warning (
" Failed to delete temporary credential after failed test " ,
credential_id = credential_id ,
organization_id = organization_id ,
exc_info = True ,
)
return
# Workflow completed — wait for session data to be persisted
session_dir = None
max_retries = 5
for attempt in range ( max_retries ) :
session_dir = await app . STORAGE . retrieve_browser_session (
organization_id = organization_id ,
workflow_permanent_id = workflow_permanent_id ,
)
if session_dir :
break
if attempt < max_retries - 1 :
LOG . info (
" Browser session not yet persisted, retrying " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
attempt = attempt + 1 ,
max_retries = max_retries ,
)
await asyncio . sleep ( 2 )
if not session_dir :
LOG . warning (
" No persisted session found after retries for credential test workflow " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
workflow_permanent_id = workflow_permanent_id ,
max_retries = max_retries ,
)
return
# Create the browser profile in DB
profile_name = f " Profile - { credential_name } ( { credential_id } ) "
profile = await app . DATABASE . create_browser_profile (
organization_id = organization_id ,
name = profile_name ,
description = f " Browser profile from credential test for { credential_name } " ,
)
# Copy session data to the browser profile storage location
await app . STORAGE . store_browser_profile (
organization_id = organization_id ,
profile_id = profile . browser_profile_id ,
directory = session_dir ,
)
# Link browser profile to credential
await app . DATABASE . update_credential (
credential_id = credential_id ,
organization_id = organization_id ,
browser_profile_id = profile . browser_profile_id ,
tested_url = test_url ,
)
LOG . info (
" Browser profile created from credential test " ,
credential_id = credential_id ,
browser_profile_id = profile . browser_profile_id ,
workflow_run_id = workflow_run_id ,
)
return
LOG . warning (
" Timed out waiting for workflow run to complete for browser profile creation " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
)
# Clean up temporary credentials on poll timeout
if credential_name . startswith ( " _test_login_ " ) :
try :
await app . DATABASE . delete_credential (
credential_id = credential_id ,
organization_id = organization_id ,
)
except Exception :
LOG . warning (
" Failed to delete temporary credential after poll timeout " ,
credential_id = credential_id ,
exc_info = True ,
)
except Exception :
LOG . exception (
" Failed to create browser profile from credential test " ,
credential_id = credential_id ,
workflow_run_id = workflow_run_id ,
)
# Clean up temporary credentials on unexpected error
if credential_name . startswith ( " _test_login_ " ) :
try :
await app . DATABASE . delete_credential (
credential_id = credential_id ,
organization_id = organization_id ,
)
except Exception :
LOG . warning (
" Failed to delete temporary credential after error " ,
credential_id = credential_id ,
exc_info = True ,
)
2026-02-11 00:04:51 -05:00
@legacy_base_router.put ( " /credentials/ {credential_id} " )
@legacy_base_router.put ( " /credentials/ {credential_id} / " , include_in_schema = False )
@base_router.post (
" /credentials/ {credential_id} /update " ,
response_model = CredentialResponse ,
summary = " Update credential " ,
description = " Overwrites the stored credential data (e.g. username/password) while keeping the same credential_id. " ,
tags = [ " Credentials " ] ,
openapi_extra = {
" x-fern-sdk-method-name " : " update_credential " ,
} ,
)
@base_router.post (
" /credentials/ {credential_id} /update/ " ,
response_model = CredentialResponse ,
include_in_schema = False ,
)
async def update_credential (
background_tasks : BackgroundTasks ,
credential_id : str = Path (
. . . ,
description = " The unique identifier of the credential to update " ,
examples = [ " cred_1234567890 " ] ,
openapi_extra = { " x-fern-sdk-parameter-name " : " credential_id " } ,
) ,
data : CreateCredentialRequest = Body (
. . . ,
description = " The new credential data to store " ,
example = {
" name " : " My Credential " ,
" credential_type " : " PASSWORD " ,
" credential " : { " username " : " user@example.com " , " password " : " newpassword123 " } ,
} ,
openapi_extra = { " x-fern-sdk-parameter-name " : " data " } ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CredentialResponse :
existing_credential = await app . DATABASE . get_credential (
credential_id = credential_id , organization_id = current_org . organization_id
)
if not existing_credential :
raise HTTPException ( status_code = 404 , detail = f " Credential not found, credential_id= { credential_id } " )
vault_type = existing_credential . vault_type or CredentialVaultType . BITWARDEN
credential_service = app . CREDENTIAL_VAULT_SERVICES . get ( vault_type )
if not credential_service :
raise HTTPException ( status_code = 400 , detail = " Unsupported credential storage type " )
old_item_id = existing_credential . item_id
updated_credential = await credential_service . update_credential (
credential = existing_credential ,
data = data ,
)
# Schedule background cleanup of old vault item if the item_id changed
if old_item_id != updated_credential . item_id :
background_tasks . add_task (
credential_service . post_delete_credential_item ,
old_item_id ,
existing_credential . organization_id ,
)
if updated_credential . vault_type == CredentialVaultType . BITWARDEN :
background_tasks . add_task ( fetch_credential_item_background , updated_credential . item_id )
return _convert_to_response ( updated_credential )
2025-05-15 19:49:42 -07:00
@legacy_base_router.delete ( " /credentials/ {credential_id} " )
@legacy_base_router.delete ( " /credentials/ {credential_id} / " , include_in_schema = False )
@base_router.post (
" /credentials/ {credential_id} /delete " ,
status_code = 204 ,
summary = " Delete credential " ,
description = " Deletes a specific credential by its ID " ,
tags = [ " Credentials " ] ,
openapi_extra = {
" x-fern-sdk-method-name " : " delete_credential " ,
2025-10-28 17:43:19 -06:00
" x-fern-examples " : [
{
" code-samples " : [
{ " sdk " : " python " , " code " : DELETE_CREDENTIAL_CODE_SAMPLE_PYTHON } ,
{ " sdk " : " typescript " , " code " : DELETE_CREDENTIAL_CODE_SAMPLE_TS } ,
]
}
] ,
2025-05-15 19:49:42 -07:00
} ,
)
2025-05-18 13:45:46 -07:00
@base_router.post (
" /credentials/ {credential_id} /delete/ " ,
status_code = 204 ,
include_in_schema = False ,
)
2025-05-15 19:49:42 -07:00
async def delete_credential (
2025-10-14 20:25:22 -06:00
background_tasks : BackgroundTasks ,
2025-05-15 19:49:42 -07:00
credential_id : str = Path (
. . . ,
description = " The unique identifier of the credential to delete " ,
2025-06-13 22:58:55 -07:00
examples = [ " cred_1234567890 " ] ,
2025-05-15 19:49:42 -07:00
openapi_extra = { " x-fern-sdk-parameter-name " : " credential_id " } ,
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > None :
credential = await app . DATABASE . get_credential (
credential_id = credential_id , organization_id = current_org . organization_id
)
if not credential :
raise HTTPException ( status_code = 404 , detail = f " Credential not found, credential_id= { credential_id } " )
2025-10-10 10:10:18 -06:00
vault_type = credential . vault_type or CredentialVaultType . BITWARDEN
credential_service = app . CREDENTIAL_VAULT_SERVICES . get ( vault_type )
if not credential_service :
raise HTTPException ( status_code = 400 , detail = " Unsupported credential storage type " )
await credential_service . delete_credential ( credential )
2025-05-15 19:49:42 -07:00
2025-10-14 20:25:22 -06:00
# Schedule background cleanup if the service implements it
2026-02-11 00:04:51 -05:00
if vault_type != CredentialVaultType . CUSTOM :
background_tasks . add_task (
credential_service . post_delete_credential_item ,
credential . item_id ,
credential . organization_id ,
)
2025-10-14 20:25:22 -06:00
2025-05-15 19:49:42 -07:00
return None
2025-05-18 12:43:22 -07:00
@legacy_base_router.get ( " /credentials/ {credential_id} " )
@legacy_base_router.get ( " /credentials/ {credential_id} / " , include_in_schema = False )
@base_router.get (
" /credentials/ {credential_id} " ,
2025-05-15 19:49:42 -07:00
response_model = CredentialResponse ,
2025-05-18 12:43:22 -07:00
summary = " Get credential by ID " ,
description = " Retrieves a specific credential by its ID " ,
2025-05-15 19:49:42 -07:00
tags = [ " Credentials " ] ,
openapi_extra = {
2025-05-18 12:43:22 -07:00
" x-fern-sdk-method-name " : " get_credential " ,
2025-10-28 17:43:19 -06:00
" x-fern-examples " : [
{
" code-samples " : [
{ " sdk " : " python " , " code " : GET_CREDENTIAL_CODE_SAMPLE_PYTHON } ,
{ " sdk " : " typescript " , " code " : GET_CREDENTIAL_CODE_SAMPLE_TS } ,
]
}
] ,
2025-05-15 19:49:42 -07:00
} ,
)
2025-05-18 13:45:46 -07:00
@base_router.get (
" /credentials/ {credential_id} / " ,
response_model = CredentialResponse ,
include_in_schema = False ,
)
2025-05-18 12:43:22 -07:00
async def get_credential (
credential_id : str = Path (
2025-05-15 19:49:42 -07:00
. . . ,
2025-05-18 12:43:22 -07:00
description = " The unique identifier of the credential " ,
2025-06-13 22:58:55 -07:00
examples = [ " cred_1234567890 " ] ,
2025-05-18 12:43:22 -07:00
openapi_extra = { " x-fern-sdk-parameter-name " : " credential_id " } ,
2025-05-15 19:49:42 -07:00
) ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CredentialResponse :
2026-02-16 23:43:25 -05:00
""" Return non-sensitive metadata for a single credential.
SECURITY : This endpoint intentionally does NOT return the raw secret
material ( password , card number , CVV , secret value , etc . ) . Only
non - sensitive fields are included in the response . See the module
docstring for the full security invariant .
"""
2025-11-04 10:29:51 -07:00
credential = await app . DATABASE . get_credential (
credential_id = credential_id , organization_id = current_org . organization_id
)
if not credential :
raise HTTPException ( status_code = 404 , detail = " Credential not found " )
2025-10-10 10:10:18 -06:00
2025-11-04 10:29:51 -07:00
return _convert_to_response ( credential )
2025-05-18 12:43:22 -07:00
@legacy_base_router.get ( " /credentials " )
@legacy_base_router.get ( " /credentials/ " , include_in_schema = False )
@base_router.get (
" /credentials " ,
response_model = list [ CredentialResponse ] ,
summary = " Get all credentials " ,
description = " Retrieves a paginated list of credentials for the current organization " ,
tags = [ " Credentials " ] ,
openapi_extra = {
" x-fern-sdk-method-name " : " get_credentials " ,
2025-10-28 17:43:19 -06:00
" x-fern-examples " : [
{
" code-samples " : [
{ " sdk " : " python " , " code " : GET_CREDENTIALS_CODE_SAMPLE_PYTHON } ,
{ " sdk " : " typescript " , " code " : GET_CREDENTIALS_CODE_SAMPLE_TS } ,
]
}
] ,
2025-05-18 12:43:22 -07:00
} ,
)
2025-05-18 13:45:46 -07:00
@base_router.get (
" /credentials/ " ,
response_model = list [ CredentialResponse ] ,
include_in_schema = False ,
)
2025-05-18 12:43:22 -07:00
async def get_credentials (
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
page : int = Query (
1 ,
ge = 1 ,
description = " Page number for pagination " ,
2025-06-13 22:58:55 -07:00
examples = [ 1 ] ,
2025-05-18 12:43:22 -07:00
openapi_extra = { " x-fern-sdk-parameter-name " : " page " } ,
) ,
page_size : int = Query (
10 ,
ge = 1 ,
description = " Number of items per page " ,
2025-06-13 22:58:55 -07:00
examples = [ 10 ] ,
2025-05-18 12:43:22 -07:00
openapi_extra = { " x-fern-sdk-parameter-name " : " page_size " } ,
) ,
) - > list [ CredentialResponse ] :
2026-02-16 23:43:25 -05:00
""" Return non-sensitive metadata for all credentials (paginated).
SECURITY : Like ` ` get_credential ` ` , this endpoint never returns raw secret
material . See the module docstring for the full security invariant .
"""
2025-11-04 10:29:51 -07:00
credentials = await app . DATABASE . get_credentials ( current_org . organization_id , page = page , page_size = page_size )
return [ _convert_to_response ( credential ) for credential in credentials ]
2025-08-05 07:34:26 -07:00
@base_router.get (
2025-08-05 23:33:08 +08:00
" /credentials/onepassword/get " ,
2025-08-05 07:34:26 -07:00
response_model = CreateOnePasswordTokenResponse ,
summary = " Get OnePassword service account token " ,
description = " Retrieves the current OnePassword service account token for the organization. " ,
2025-08-14 09:08:47 -07:00
include_in_schema = False ,
2025-08-05 07:34:26 -07:00
)
@base_router.get (
2025-08-05 23:33:08 +08:00
" /credentials/onepassword/get/ " ,
2025-08-05 07:34:26 -07:00
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 ,
2025-09-26 16:35:47 -07:00
token_type = OrganizationAuthTokenType . onepassword_service_account . value ,
2025-08-05 07:34:26 -07:00
)
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 (
2025-08-05 23:33:08 +08:00
" /credentials/onepassword/create " ,
2025-08-05 07:34:26 -07:00
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. " ,
2025-08-14 09:08:47 -07:00
include_in_schema = False ,
2025-08-05 07:34:26 -07:00
)
@base_router.post (
2025-08-05 23:33:08 +08:00
" /credentials/onepassword/create/ " ,
2025-08-05 07:34:26 -07:00
response_model = CreateOnePasswordTokenResponse ,
include_in_schema = False ,
)
async def update_onepassword_token (
2025-08-14 08:29:13 -07:00
data : CreateOnePasswordTokenRequest ,
2025-08-05 07:34:26 -07:00
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 ) } " ,
)
2025-09-23 10:16:48 -06:00
@base_router.get (
" /credentials/azure_credential/get " ,
response_model = AzureClientSecretCredentialResponse ,
summary = " Get Azure Client Secret Credential " ,
description = " Retrieves the current Azure Client Secret Credential for the organization. " ,
include_in_schema = False ,
)
@base_router.get (
" /credentials/azure_credential/get/ " ,
response_model = AzureClientSecretCredentialResponse ,
include_in_schema = False ,
)
async def get_azure_client_secret_credential (
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > AzureClientSecretCredentialResponse :
"""
Get the current Azure Client Secret Credential for the organization .
"""
try :
auth_token = await app . DATABASE . get_valid_org_auth_token (
organization_id = current_org . organization_id ,
2025-09-26 16:35:47 -07:00
token_type = OrganizationAuthTokenType . azure_client_secret_credential . value ,
2025-09-23 10:16:48 -06:00
)
if not auth_token :
raise HTTPException (
status_code = 404 ,
detail = " No Azure Client Secret Credential found for this organization " ,
)
return AzureClientSecretCredentialResponse ( token = auth_token )
except HTTPException :
raise
except Exception as e :
LOG . error (
" Failed to get Azure Client Secret Credential " ,
organization_id = current_org . organization_id ,
error = str ( e ) ,
exc_info = True ,
)
raise HTTPException (
status_code = 500 ,
detail = f " Failed to get Azure Client Secret Credential: { str ( e ) } " ,
)
@base_router.post (
" /credentials/azure_credential/create " ,
response_model = AzureClientSecretCredentialResponse ,
summary = " Create or update Azure Client Secret Credential " ,
description = " Creates or updates a Azure Client Secret Credential for the current organization. Only one valid record is allowed per organization. " ,
include_in_schema = False ,
)
@base_router.post (
" /credentials/azure_credential/create/ " ,
response_model = AzureClientSecretCredentialResponse ,
include_in_schema = False ,
)
async def update_azure_client_secret_credential (
request : CreateAzureClientSecretCredentialRequest ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > AzureClientSecretCredentialResponse :
"""
Create or update an Azure Client Secret Credential for the current organization .
This endpoint ensures only one valid Azure Client Secret Credential exists per organization .
If a valid token already exists , it will be invalidated before creating the new one .
"""
try :
# Invalidate any existing valid Azure Client Secret Credential for this organization
await app . DATABASE . invalidate_org_auth_tokens (
organization_id = current_org . organization_id ,
token_type = OrganizationAuthTokenType . azure_client_secret_credential ,
)
# Create the new Azure token
auth_token = await app . DATABASE . create_org_auth_token (
organization_id = current_org . organization_id ,
token_type = OrganizationAuthTokenType . azure_client_secret_credential ,
token = request . credential ,
)
LOG . info (
" Created or updated Azure Client Secret Credential " ,
organization_id = current_org . organization_id ,
token_id = auth_token . id ,
)
return AzureClientSecretCredentialResponse ( token = auth_token )
except Exception as e :
LOG . error (
" Failed to create or update Azure Client Secret Credential " ,
organization_id = current_org . organization_id ,
error = str ( e ) ,
exc_info = True ,
)
raise HTTPException (
status_code = 500 ,
detail = f " Failed to create or update Azure Client Secret Credential: { str ( e ) } " ,
)
2025-10-10 10:10:18 -06:00
2025-12-03 06:55:04 +05:30
@base_router.get (
" /credentials/custom_credential/get " ,
response_model = CustomCredentialServiceConfigResponse ,
summary = " Get Custom Credential Service Configuration " ,
description = " Retrieves the current custom credential service configuration for the organization. " ,
include_in_schema = False ,
)
@base_router.get (
" /credentials/custom_credential/get/ " ,
response_model = CustomCredentialServiceConfigResponse ,
include_in_schema = False ,
)
async def get_custom_credential_service_config (
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CustomCredentialServiceConfigResponse :
"""
Get the current custom credential service configuration for the organization .
"""
try :
auth_token = await app . DATABASE . get_valid_org_auth_token (
organization_id = current_org . organization_id ,
token_type = OrganizationAuthTokenType . custom_credential_service . value ,
)
if not auth_token :
raise HTTPException (
status_code = 404 ,
detail = " No custom credential service configuration found for this organization " ,
)
return CustomCredentialServiceConfigResponse ( token = auth_token )
except HTTPException :
raise
except Exception as e :
LOG . error (
" Failed to get custom credential service configuration " ,
organization_id = current_org . organization_id ,
error = str ( e ) ,
exc_info = True ,
)
raise HTTPException (
status_code = 500 ,
detail = f " Failed to get custom credential service configuration: { e !s} " ,
) from e
@base_router.post (
" /credentials/custom_credential/create " ,
response_model = CustomCredentialServiceConfigResponse ,
summary = " Create or update Custom Credential Service Configuration " ,
description = " Creates or updates a custom credential service configuration for the current organization. Only one valid configuration is allowed per organization. " ,
include_in_schema = False ,
)
@base_router.post (
" /credentials/custom_credential/create/ " ,
response_model = CustomCredentialServiceConfigResponse ,
include_in_schema = False ,
)
async def update_custom_credential_service_config (
request : CreateCustomCredentialServiceConfigRequest ,
current_org : Organization = Depends ( org_auth_service . get_current_org ) ,
) - > CustomCredentialServiceConfigResponse :
"""
Create or update a custom credential service configuration for the current organization .
This endpoint ensures only one valid custom credential service configuration exists per organization .
If a valid configuration already exists , it will be invalidated before creating the new one .
"""
try :
# Invalidate any existing valid custom credential service configuration for this organization
await app . DATABASE . invalidate_org_auth_tokens (
organization_id = current_org . organization_id ,
token_type = OrganizationAuthTokenType . custom_credential_service ,
)
# Store the configuration as JSON in the token field
config_json = json . dumps ( request . config . model_dump ( ) )
# Create the new configuration
auth_token = await app . DATABASE . create_org_auth_token (
organization_id = current_org . organization_id ,
token_type = OrganizationAuthTokenType . custom_credential_service ,
token = config_json ,
)
LOG . info (
" Created or updated custom credential service configuration " ,
organization_id = current_org . organization_id ,
token_id = auth_token . id ,
)
return CustomCredentialServiceConfigResponse ( token = auth_token )
except Exception as e :
LOG . error (
" Failed to create or update custom credential service configuration " ,
organization_id = current_org . organization_id ,
error = str ( e ) ,
exc_info = True ,
)
raise HTTPException (
status_code = 500 ,
detail = f " Failed to create or update custom credential service configuration: { e !s} " ,
) from e
2025-11-04 10:29:51 -07:00
async def _get_credential_vault_service ( ) - > CredentialVaultService :
if settings . CREDENTIAL_VAULT_TYPE == CredentialVaultType . BITWARDEN :
2025-10-10 10:10:18 -06:00
return app . BITWARDEN_CREDENTIAL_VAULT_SERVICE
elif settings . CREDENTIAL_VAULT_TYPE == CredentialVaultType . AZURE_VAULT :
if not app . AZURE_CREDENTIAL_VAULT_SERVICE :
raise HTTPException ( status_code = 400 , detail = " Azure Vault credential is not supported " )
return app . AZURE_CREDENTIAL_VAULT_SERVICE
2025-12-03 06:55:04 +05:30
elif settings . CREDENTIAL_VAULT_TYPE == CredentialVaultType . CUSTOM :
if not app . CUSTOM_CREDENTIAL_VAULT_SERVICE :
raise HTTPException ( status_code = 400 , detail = " Custom credential vault is not supported " )
return app . CUSTOM_CREDENTIAL_VAULT_SERVICE
2025-10-10 10:10:18 -06:00
else :
raise HTTPException ( status_code = 400 , detail = " Credential storage not supported " )
2025-11-04 10:29:51 -07:00
def _convert_to_response ( credential : Credential ) - > CredentialResponse :
2026-02-16 23:43:25 -05:00
""" Convert an internal ``Credential`` to a safe API response.
SECURITY : This function must ONLY copy non - sensitive metadata into the
response . Never include passwords , TOTP secrets , full card numbers , CVVs ,
expiration dates , card holder names , or secret values . See the module
docstring for the full security invariant .
"""
2025-11-04 10:29:51 -07:00
if credential . credential_type == CredentialType . PASSWORD :
credential_response = PasswordCredentialResponse (
username = credential . username or credential . credential_id ,
totp_type = credential . totp_type ,
)
return CredentialResponse (
credential = credential_response ,
credential_id = credential . credential_id ,
credential_type = credential . credential_type ,
name = credential . name ,
2026-02-20 02:23:32 +03:00
browser_profile_id = credential . browser_profile_id ,
tested_url = credential . tested_url ,
2025-11-04 10:29:51 -07:00
)
elif credential . credential_type == CredentialType . CREDIT_CARD :
credential_response = CreditCardCredentialResponse (
last_four = credential . card_last4 or " **** " ,
brand = credential . card_brand or " Card Brand " ,
)
return CredentialResponse (
credential = credential_response ,
credential_id = credential . credential_id ,
credential_type = credential . credential_type ,
name = credential . name ,
2026-02-20 02:23:32 +03:00
browser_profile_id = credential . browser_profile_id ,
tested_url = credential . tested_url ,
2025-11-04 10:29:51 -07:00
)
2025-12-09 11:19:57 -08:00
elif credential . credential_type == CredentialType . SECRET :
credential_response = SecretCredentialResponse ( secret_label = credential . secret_label )
return CredentialResponse (
credential = credential_response ,
credential_id = credential . credential_id ,
credential_type = credential . credential_type ,
name = credential . name ,
2026-02-20 02:23:32 +03:00
browser_profile_id = credential . browser_profile_id ,
tested_url = credential . tested_url ,
2025-12-09 11:19:57 -08:00
)
2025-11-04 10:29:51 -07:00
else :
raise HTTPException ( status_code = 400 , detail = " Credential type not supported " )