Docs
API 1.39
Status
Guides

Authenticated flows

Sign in with a password and a one-time code, save the session, and let the next job start already signed in.

This guide builds a program that signs in to a site protected by a password and a one-time code: the credentials come from secret inputs, the job parks to ask you for the code, and the signed-in state is saved as a session. The next job with that session skips the login entirely, so you are asked for a code once, not on every run.

#When to use it

Use this pattern when you (your backend, your script, an operator) can supply the one-time code: you read it from an authenticator, a shared mailbox, or an SMS gateway, and answer through the API or the SDK.

When the code arrives on someone else's phone, such as your customer's, do not relay it through your own systems. Declare the input as hosted instead and send that person a link to a page where they type it themselves: see Hosted resolve pages. The program is the same apart from one key.

If a site needs no second factor, you still want the session part of this guide: sign in once, save the session, and stop sending the password on every job.

#The building blocks

#Secret inputs

Declare the password as a secret input. Its value is given per job and never appears in the job's meta, in logs, in snapshots, in screenshots or in webhooks; a field the value was filled into is redacted.

"inputs": {
  "email": { "type": "string", "default": "[email protected]" },
  "password": { "type": "secret" }
}

A secret can be filled into a field, and nothing else: lint refuses a program that reads a secret in an extract or renders it into a URL (E013).

TypeScript
// `password` is declared `{ "type": "secret" }` in the program: it is encrypted at rest, never
// returned, and redacted from logs, step records and snapshots.
const job = await kaab.jobs.create({
  program: name,
  inputs: {
    base_url: `${site}`,
    password: process.env.LOGIN_PASSWORD ?? "secret",
  },
});
Python
# password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
# and redacted from logs, step records and snapshots.
password = os.environ.get("LOGIN_PASSWORD", "secret")
job = kaab.jobs.create(program=name, inputs={"base_url": f"{site}", "password": password})
Go
// password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
// and redacted from logs, step records and snapshots.
password := os.Getenv("LOGIN_PASSWORD")
if password == "" {
    password = "secret"
}
body, err := kaab.Body[client.CreateJobJSONBody](map[string]any{
    "program": name,
    "inputs":  map[string]any{"base_url": site + "", "password": password},
})
if err != nil {
    log.Fatal(err)
}
job, err := c.Jobs.Create(ctx, body)
if err != nil {
    log.Fatal(err)
}

#Parking for the code

An await_input step stops the job and asks for a named value:

{ "await_input": { "name": "otp", "prompt": "The code from your authenticator", "type": "string",
  "pattern": "^[0-9]{6}$", "ttl": "5m", "expires": "24h", "collect": "api" } }

The job's status becomes awaiting_input, the job.awaiting_input event is sent, and the envelope carries an input_request with the name, the prompt, the pattern, a screenshot of the page and two deadlines:

  • ttl (default 5 minutes, at most 15) is how long the browser stays open on the page. Answer inside it and the run carries on in that same page.
  • expires (at most 7 days) is how long the job waits for the value at all. Answer after ttl but before expires and the job restores its last checkpoint and continues; past expires the answer is refused with 410 input_expired.

The value you send lands in vars.otp, which the next step fills in with {{vars.otp}}.

#Answering

Post the value to POST /v1/jobs/{id}/input, keyed by the input's name:

{ "otp": "482913" }

The job answers 200 and is running again, or queued when its browser was already released. 409 job_not_waiting means the job is not parked on an input any more.

With an SDK, jobs.wait does this for you: give it an onInput handler, and whenever the job parks on an input, the handler's return value is posted and the wait continues to the end.

TypeScript
await kaab.programs.push({ ...definition, name }, { publish: true });

// `password` is declared `{ "type": "secret" }` in the program: it is encrypted at rest, never
// returned, and redacted from logs, step records and snapshots.
const job = await kaab.jobs.create({
  program: name,
  inputs: {
    base_url: `${site}`,
    password: process.env.LOGIN_PASSWORD ?? "secret",
  },
});

// The job parks at the code; `onInput` answers it and the wait goes on.
const done = await kaab.jobs.wait(job.id, {
  timeoutMs: 300_000,
  onInput: async (request) => {
    console.log(`${job.id} asks for ${request.name}`);
    return askForCode(request.prompt ?? request.name);
  },
});
console.log(`${done.id}: ${done.status}; session saved: ${done.meta.session?.saved === true}`);
Python
# password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
# and redacted from logs, step records and snapshots.
password = os.environ.get("LOGIN_PASSWORD", "secret")
job = kaab.jobs.create(program=name, inputs={"base_url": f"{site}", "password": password})
# The job parks at the code; on_input answers it and the wait goes on.
done = kaab.jobs.wait(job.id, timeout=300, on_input=ask_for_code)
Go
name := fmt.Sprintf("login-2fa-example-%x", time.Now().UnixMilli())
publish := true
if _, err := c.Programs.Push(ctx, definition, kaab.PushOptions{Name: name, Publish: &publish}); err != nil {
    log.Fatal(err)
}
defer func() { _ = c.Programs.Delete(ctx, name) }()

// password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
// and redacted from logs, step records and snapshots.
password := os.Getenv("LOGIN_PASSWORD")
if password == "" {
    password = "secret"
}
body, err := kaab.Body[client.CreateJobJSONBody](map[string]any{
    "program": name,
    "inputs":  map[string]any{"base_url": site + "", "password": password},
})
if err != nil {
    log.Fatal(err)
}
job, err := c.Jobs.Create(ctx, body)
if err != nil {
    log.Fatal(err)
}
// The job parks at the code; OnInput answers it and the wait goes on.
done, err := c.Jobs.Wait(ctx, job.ID, kaab.WaitOptions{Timeout: 5 * time.Minute, OnInput: askForCode})
if err != nil {
    log.Fatal(err)
}
if done.Status != "succeeded" {
    log.Fatalf("job %s is %s", done.ID, done.Status)
}
fmt.Printf("%s: signed in\n", done.ID)

#Saving the session

A checkpoint with save_session writes the browser's signed-in state to a named session:

{ "checkpoint": { "name": "logged_in", "save_session": "portal-login" } }

Bind the same session in the program's options and let the program start after that checkpoint whenever the session is still valid:

"options": { "max_tier": "browser", "session": "portal-login", "resume_from_session": true }

Before each job, Kaabist checks the session against the program's logged_in and login_page landmarks. If the session is valid, the job starts at the step after the checkpoint, and the login steps, the await_input included, never run. If it has expired, the program runs from the top and signs in again; policies.on_session_expired (default refresh) decides what happens when a session expires in the middle of a run.

#Worked example

Take a customer portal at https://portal.example.com that asks for an e-mail address and a password, then a six-digit code, then shows a report of 40 rows. The program below signs in, asks for the code only when the code form is actually shown, saves the session, and reads the report:

{
  "kaab": "1",
  "name": "portal-report",
  "inputs": {
    "base_url": { "type": "string", "default": "https://portal.example.com" },
    "email": { "type": "string", "default": "[email protected]" },
    "password": { "type": "secret" },
    "report_id": { "type": "string", "default": "1", "pattern": "^[0-9]+$" }
  },
  "options": { "max_tier": "browser", "session": "portal-login", "resume_from_session": true },
  "landmarks": {
    "login_page": { "visible": "form[action$='/login']" },
    "logged_in": { "url": "**/dashboard" }
  },
  "steps": [
    { "id": "open", "goto": "{{inputs.base_url}}/login", "expect": { "landmark": "login_page" } },
    { "id": "email", "fill": "input[name=email]", "value": "{{inputs.email}}" },
    { "id": "password", "fill": "input[name=password]", "value": "{{inputs.password}}" },
    {
      "id": "submit", "click": "form button[type=submit]", "side_effect": true,
      "expect": { "any": [{ "visible": "input[name=code]" }, { "landmark": "logged_in" }] }
    },
    {
      "id": "second-factor",
      "when": { "visible": "input[name=code]" },
      "do": [
        { "id": "ask-otp", "await_input": { "name": "otp", "prompt": "The code from your authenticator", "type": "string",
          "pattern": "^[0-9]{6}$", "ttl": "5m", "expires": "24h", "collect": "api" } },
        { "id": "fill-otp", "fill": "input[name=code]", "value": "{{vars.otp}}" },
        { "id": "verify", "click": "form button[type=submit]", "side_effect": true,
          "expect": { "landmark": "logged_in" } }
      ]
    },
    { "id": "save", "checkpoint": { "name": "logged_in", "save_session": "portal-login" } },
    {
      "id": "report", "goto": "{{inputs.base_url}}/reports/{{inputs.report_id}}",
      "expect": { "count": { "target": "#report tbody tr", "min": 40 } }
    },
    {
      "id": "rows",
      "extract": {
        "rows": {
          "selector": "#report tbody tr", "many": true, "min_items": 40,
          "fields": {
            "date": { "selector": "td:nth-child(1)", "type": "date", "date_format": "dd.MM.yyyy" },
            "description": "td:nth-child(2)",
            "amount": { "selector": "td:nth-child(3)", "type": "money", "locale": "tr", "currency": "TRY" }
          }
        }
      }
    }
  ],
  "policies": { "on_session_expired": "refresh", "screenshot": "on_failure" }
}

Run it with the account's password:

  1. Create the job with inputs: { "password": "<the account password>" }. It runs the login and parks awaiting_input with input_request.name set to otp.
  2. Post the code to POST /v1/jobs/{id}/input, or return it from the onInput handler as in the snippet above, for example { "otp": "482913" }.
  3. The job verifies the code, saves the session portal-login and reads the report. The envelope shows status: "succeeded", 40 rows in data.rows and meta.session.saved: true.
  4. Create a second job with the same inputs. Its session is valid, so it starts after the checkpoint: meta.steps has no await_input step, and nobody is asked for a code.

#Pitfalls

  • Clicks that sign in are side effects. Mark the submit buttons side_effect: true, as above, so that a retry never presses them twice; see side-effect safety.
  • A second factor that only sometimes appears. Wrap the await_input in a when, as the worked example does. An unconditional await_input asks for a code even when the site trusted the device and skipped it.
  • One session, one job at a time. A session is exclusive by default: a second job that binds it waits until the first one ends. Run parallel work under different sessions.
  • The browser window is short. ttl is at most 15 minutes. A late answer still works, until expires, but costs a few seconds while the job restores its checkpoint.
  • Login programs are rate limited and gated. A program that fills a password field or a secret input runs at most 60 times an hour per organisation (429 login_flow_rate_limited), and needs a verified payment method on your organisation. Reusing a session keeps you well under the limit.
  • Never put a password in default. A default is stored with the program version; a secret is given per job.

#Reference

Was this page helpful?