diff --git a/auth/connection-lifecycle.mdx b/auth/connection-lifecycle.mdx index 535002e..6fbc57c 100644 --- a/auth/connection-lifecycle.mdx +++ b/auth/connection-lifecycle.mdx @@ -12,10 +12,14 @@ After the initial login, every connection moves through this loop: - On a configurable cadence, Kernel spins up a browser with the profile and verifies the session is still logged in. If it is, nothing else happens until the next check. + On a configurable cadence, Kernel spins up a browser with the profile and verifies the session. A check reaches one of three conclusions: still logged in, definitely logged out, or **inconclusive** — the page didn't prove either way. + + A check needs concrete evidence to conclude. If the site returns a challenge page, times out, partially loads, or shows something ambiguous, the result is inconclusive and the connection is left exactly as it was. Nothing happens until the next check. - If the check finds the session expired and the connection's `can_reauth` is `true`, Kernel runs the saved login flow with the stored credentials in the background. A successful login resets the loop. + If the check finds the session **definitely** expired and the connection's `can_reauth` is `true`, Kernel runs the saved login flow with the stored credentials in the background. A successful login resets the loop. + + An inconclusive check never triggers reauth. Kernel would rather check again on the next cycle than log in again unnecessarily — a spurious login can trip risk checks on the site, prompt a device-verification email, or invalidate a working session. If auto-reauth isn't possible — credentials aren't linked, the saved flow requires human input, or the login keeps failing — the connection's `status` flips to `NEEDS_AUTH` and a new login session is required. @@ -71,9 +75,9 @@ If you're seeing the connection flip to `NEEDS_AUTH` frequently and want shorter Check the `can_reauth` boolean on a connection. It's `true` only when **both** of these hold: 1. **A credential is linked** — stored in Kernel or sourced via [1Password](/integrations/1password). -2. **No external action is required** — the saved login flow doesn't need a human (no SMS/email OTP, no push notification, no manual MFA selection). +2. **No known human-only requirement is recorded** — for example, an sms/email code, push approval, or other external action. -If either fails, the connection will move to `NEEDS_AUTH` on the next expired session and wait for a fresh login. +If either fails, the connection will move to `NEEDS_AUTH` on the next expired session and wait for a fresh login. A site can still present a new or conditional challenge during reauth; if Kernel can't resolve it safely, reauth stops and the connection moves to `NEEDS_AUTH`. ### External actions that block auto-reauth @@ -127,24 +131,32 @@ if state.Status == kernel.ManagedAuthStatusNeedsAuth { ## When a login fails -If a login attempt fails — whether triggered by a health check, an auto-reauth, or a manual `.login()` — Kernel retries with exponential backoff. After repeated failures the flow is marked failed and the connection surfaces an error code on `flow_status`. +If a login attempt fails — whether triggered by a health check, an auto-reauth, or a manual `.login()` — the flow is marked `FAILED` and the event includes `error_code` and `error_message`. + +How much Kernel retries depends on the failure. A transient site problem (a 5xx page, a maintenance screen) is retried once against the login page before giving up. A conclusive rejection by the site — wrong credentials, a locked account, an unsupported method — is **not** retried, because retrying would burn attempts against a lockout or fail identically. Common codes: -| Code | Meaning | -|------|---------| -| `credentials_invalid` | The stored or submitted credentials were rejected by the site. | -| `bot_detected` | The login page blocked the session as automated. | -| `captcha_blocked` | A CAPTCHA was presented and couldn't be solved. | -| `unsupported_auth_method` | The site required a method Kernel doesn't currently support (e.g. passkeys). | +| Code | Meaning | Retried | +|------|---------|---------| +| `credentials_invalid` | The stored or submitted credentials were rejected by the site. | No | +| `account_locked` | The site locked or suspended the account. | No | +| `unsupported_auth_method` | The site required a method Kernel doesn't currently support (e.g. passkeys). | No | +| `rate_limited` | The site rate-limited the login attempt. | No; retry later | +| `website_error` | The site returned an error, maintenance, or unavailable page. | Once | +| `bot_detected` | The login page blocked the session as automated. | No | +| `captcha_blocked` | A CAPTCHA was presented and couldn't be solved. | No | See the [API reference](https://kernel.sh/docs/api-reference/managed-auth/start-login-flow) for the full list. ### Recovering -- **`credentials_invalid`** — Update the linked [credential](/auth/credentials) and call `.login()` to re-run the flow. +- **`credentials_invalid`** — Update the linked [credential](/auth/credentials) and call `.login()` to re-run the flow. When the site identifies which field it rejected during an interactive login, Kernel asks for a corrected value in place — see [replacing a rejected credential](/auth/programmatic#replacing-a-rejected-credential). +- **`account_locked`** — Unlock the account with the site directly. Calling `.login()` again before that will not help and may extend the lockout. +- **`rate_limited`** — Wait for the site's retry window before starting another login. - **`bot_detected` / `captcha_blocked`** — Pin the connection to a cleaner [proxy](/auth/configuration#custom-proxy) (ISP or custom). For aggressive sites, also enable stealth and review the [bot detection guide](/browsers/bot-detection/overview). - **`unsupported_auth_method`** — Switch the account to a supported sign-in method (e.g. password + TOTP instead of a passkey) and re-link the credential. +- **`website_error`** — Usually the site, not the connection. Retry later; if it persists, confirm the connection's `login_url` still points at a working login page. ## Debugging a flaky connection diff --git a/auth/credentials.mdx b/auth/credentials.mdx index af00bf4..afd46f4 100644 --- a/auth/credentials.mdx +++ b/auth/credentials.mdx @@ -314,14 +314,17 @@ const login = await kernel.auth.connections.login(auth.id); // Stream state changes and submit the missing password const authEvents = await kernel.auth.connections.follow(auth.id); for await (const event of authEvents) { + const passwordField = event.fields?.find(field => field.ref === 'password'); if ( event.event === 'managed_auth_state' && event.flow_step === 'AWAITING_INPUT' && - event.discovered_fields?.length + event.interaction_id && + passwordField ) { - // Only password field will be pending (email auto-filled from credential) + // Only password is pending; email is filled from the stored credential. await kernel.auth.connections.submit(auth.id, { - fields: { password: 'user-provided-password' } + interaction_id: event.interaction_id, + field_values: { [passwordField.id]: 'user-provided-password' }, }); } } @@ -347,15 +350,21 @@ login = await kernel.auth.connections.login(auth.id) # Stream state changes and submit the missing password auth_events = await kernel.auth.connections.follow(auth.id) async for event in auth_events: + password_field = next( + (field for field in (event.fields or []) if field.ref == "password"), + None, + ) if ( event.event == "managed_auth_state" and event.flow_step == "AWAITING_INPUT" - and event.discovered_fields + and event.interaction_id + and password_field ): - # Only password field will be pending (email auto-filled from credential) + # Only password is pending; email is filled from the stored credential. await kernel.auth.connections.submit( auth.id, - fields={"password": "user-provided-password"}, + interaction_id=event.interaction_id, + field_values={password_field.id: "user-provided-password"}, ) # TOTP auto-submitted from credential → SUCCESS ``` @@ -398,16 +407,26 @@ _ = login authEvents := client.Auth.Connections.FollowStreaming(ctx, auth.ID) for authEvents.Next() { event := authEvents.Current() - if event.Event == "managed_auth_state" && event.FlowStep == "AWAITING_INPUT" && len(event.DiscoveredFields) > 0 { - // Only password field will be pending (email auto-filled from credential) + if event.Event != "managed_auth_state" || event.FlowStep != "AWAITING_INPUT" || event.InteractionID == "" { + continue + } + for _, field := range event.Fields { + if field.Ref != "password" { + continue + } + // Only password is pending; email is filled from the stored credential. _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - Fields: map[string]string{"password": "user-provided-password"}, + InteractionID: kernel.String(event.InteractionID), + FieldValues: map[string]string{ + field.ID: "user-provided-password", + }, }, }) if err != nil { panic(err) } + break } } if err := authEvents.Err(); err != nil { diff --git a/auth/faq.mdx b/auth/faq.mdx index 16e1f0e..7f62f71 100644 --- a/auth/faq.mdx +++ b/auth/faq.mdx @@ -6,9 +6,9 @@ title: FAQ When you link credentials to a connection, Kernel runs periodic health checks, detects logged-out sessions, and re-authenticates in the background so the profile stays logged in. See [Connection Lifecycle](/auth/connection-lifecycle) for the full lifecycle, cadence options, and `can_reauth` rules. -## What are sign-in options? +## What are auth choices? -Sign-in options are non-MFA choices that some sites present during login, such as account pickers ("Which account do you want to use?") or organization selectors. Unlike MFA options which are security challenges, sign-in options are informational choices that route the login flow. They appear in the session state as `sign_in_options` and are submitted via `sign_in_option_id`. See the [Programmatic flow guide](/auth/programmatic#sign-in-options-accountorg-pickers) for details and SDK examples. +Auth choices are visible routes a site presents during login, including mfa methods, sso providers, account pickers, and organization selectors. They appear in the canonical `choices` array. Submit the exact returned id with `interaction_id` and `selected_choice_id`. See the [programmatic flow guide](/auth/programmatic#choices) for examples. ## Which authentication methods are supported? @@ -20,7 +20,7 @@ Passkey-based authentication (e.g., Google accounts with passkeys enabled) is no ## What happens if login fails? -Kernel retries with exponential backoff, then surfaces an error code (`credentials_invalid`, `bot_detected`, `captcha_blocked`, etc.). See [Connection Lifecycle](/auth/connection-lifecycle#when-a-login-fails) for the full list and recovery steps. +Kernel surfaces an error code (`credentials_invalid`, `account_locked`, `bot_detected`, `captcha_blocked`, etc.). Transient site failures are retried; a conclusive rejection by the site isn't, so Kernel doesn't burn attempts against a locked account or resubmit credentials the site already refused. See [Connection Lifecycle](/auth/connection-lifecycle#when-a-login-fails) for the full list and recovery steps. ## Can I use Managed Auth with any website? diff --git a/auth/programmatic.mdx b/auth/programmatic.mdx index 00c1ef0..1e2b921 100644 --- a/auth/programmatic.mdx +++ b/auth/programmatic.mdx @@ -3,45 +3,58 @@ title: "Programmatic Flow" description: "Build your own credential collection UI with full control" --- -Build your own credential collection UI instead of using the hosted page. Stream login events, then submit credentials via the API. +build your own credential collection ui instead of using the hosted page. stream login events, render the canonical fields and choices, then submit the user's response with the current interaction id. -Use the Programmatic flow when: -- You need a custom credential collection UI that matches your app's design -- You're building headless/automated authentication -- You have credentials stored and want to authenticate without user interaction +use the programmatic flow when: -## How It Works +- you need a custom credential collection ui +- you're building headless authentication +- you already store credentials and want to handle only the inputs KERNEL cannot resolve automatically + +## How it works - - Same as [Hosted UI](/auth/hosted-ui) + + Create a managed auth connection, then call `.login()`. - - Follow the connection's SSE stream and submit credentials when `flow_step` becomes `AWAITING_INPUT` + + Follow the connection's sse stream. When `flow_step` becomes `AWAITING_INPUT`, render `fields` and `choices` from the event. - - If more fields appear (2FA code), submit again—same loop handles it + + Send the event's `interaction_id` with either `field_values` or `selected_choice_id`. Keep listening because the next page may produce another interaction. -## Getting started +## Interaction contract + +Every paused interaction uses these properties together: + +| Property | Purpose | +|---|---| +| `interaction_id` | Opaque id for the current pause. It changes when the actionable screen changes. | +| `fields` | Values the user must provide. | +| `choices` | Visible routes the user may select, including mfa, sso, account, and organization choices. | + +Submit the current `interaction_id` with either `field_values` or `selected_choice_id`. Do not mix properties from different events. KERNEL rejects stale interaction ids so a delayed submission cannot act on a newer screen. + +## Get started -### 1. Create a Connection +### 1. Create a connection -A **Managed Auth Connection** attaches an authenticated domain to a [profile](/auth/profiles) so you can use the auth connection in future browsers. A single profile can hold multiple auth connections — create one connection for each domain you want to keep authenticated on that profile. +A managed auth connection attaches one authenticated domain to a [profile](/auth/profiles). A profile can hold multiple connections. ```typescript TypeScript const auth = await kernel.auth.connections.create({ domain: 'github.com', - profile_name: 'github-profile', // Name of the profile to associate with the connection + profile_name: 'github-profile', }); ``` ```python Python auth = await kernel.auth.connections.create( domain="github.com", - profile_name="github-profile", # Name of the profile to associate with the connection + profile_name="github-profile", ) ``` @@ -49,723 +62,287 @@ auth = await kernel.auth.connections.create( auth, err := client.Auth.Connections.New(ctx, kernel.AuthConnectionNewParams{ ManagedAuthCreateRequest: kernel.ManagedAuthCreateRequestParam{ Domain: "github.com", - ProfileName: "github-profile", // Name of the profile to associate with the connection + ProfileName: "github-profile", }, }) if err != nil { panic(err) } -_ = auth ``` -### 2. Start a Login Session +### 2. Start a login session ```typescript TypeScript -const login = await kernel.auth.connections.login(auth.id); +await kernel.auth.connections.login(auth.id); ``` ```python Python -login = await kernel.auth.connections.login(auth.id) +await kernel.auth.connections.login(auth.id) ``` ```go Go -login, err := client.Auth.Connections.Login(ctx, auth.ID, kernel.AuthConnectionLoginParams{}) +_, err = client.Auth.Connections.Login(ctx, auth.ID, kernel.AuthConnectionLoginParams{}) if err != nil { panic(err) } -_ = login ``` -Credentials are saved automatically on successful login, enabling automatic re-authentication when the session expires. +A successful interactive login can save submitted credentials for automatic re-authentication. -### 3. Stream and submit credentials +### 3. Stream and submit -A single SSE stream handles everything—initial login, 2FA, and completion: +Listen for `AWAITING_INPUT`. Submit fields or a selected choice, then keep listening for the next interaction. - ```typescript TypeScript const events = await kernel.auth.connections.follow(auth.id); -let finalState; for await (const event of events) { - if (event.event !== 'managed_auth_state') continue; - finalState = event; - - // Submit when fields are ready (login or 2FA) - if (event.flow_step === 'AWAITING_INPUT' && event.discovered_fields?.length) { - const fieldValues = getCredentialsForFields(event.discovered_fields); - await kernel.auth.connections.submit(auth.id, { fields: fieldValues }); + if ( + event.event !== 'managed_auth_state' || + event.flow_step !== 'AWAITING_INPUT' || + !event.interaction_id + ) { + continue; } -} - -if (finalState?.flow_status === 'SUCCESS') { - console.log('Authentication successful!'); -} -``` - -```python Python -events = await kernel.auth.connections.follow(auth.id) -final_state = None - -async for event in events: - if event.event != "managed_auth_state": - continue - final_state = event - - # Submit when fields are ready (login or 2FA) - if event.flow_step == "AWAITING_INPUT" and event.discovered_fields: - field_values = get_credentials_for_fields(event.discovered_fields) - await kernel.auth.connections.submit(auth.id, fields=field_values) - -if final_state and final_state.flow_status == "SUCCESS": - print("Authentication successful!") -``` - -```go Go -events := client.Auth.Connections.FollowStreaming(ctx, auth.ID) -authenticated := false - -for events.Next() { - event := events.Current() - if event.Event != "managed_auth_state" { - continue - } - if event.FlowStatus == "SUCCESS" { - authenticated = true - } - - // Submit when fields are ready (login or 2FA) - if event.FlowStep == "AWAITING_INPUT" && len(event.DiscoveredFields) > 0 { - fieldValues := map[string]string{} - missingFields := []string{} - - for _, field := range event.DiscoveredFields { - switch field.Name { - case "username": - fieldValues[field.Name] = "dev-user" - case "email": - fieldValues[field.Name] = "dev@example.com" - case "password": - fieldValues[field.Name] = "correct-horse-battery-staple" - case "otp", "code", "totp": - fieldValues[field.Name] = "123456" - default: - switch field.Type { - case "email": - fieldValues[field.Name] = "dev@example.com" - case "password": - fieldValues[field.Name] = "correct-horse-battery-staple" - case "code", "totp": - fieldValues[field.Name] = "123456" - default: - missingFields = append(missingFields, field.Name) - } - } - } - - if len(missingFields) > 0 { - fmt.Println("Collect values for fields:", missingFields) - break - } - - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - Fields: fieldValues, - }, - }) - if err != nil { - panic(err) - } - } -} -if err := events.Err(); err != nil { - panic(err) -} - -if authenticated { - fmt.Println("Authentication successful!") -} -``` - - -The `discovered_fields` array tells you what the login form needs: - -```typescript -// Example discovered_fields for login -[{ name: 'username', type: 'text' }, { name: 'password', type: 'password' }] - -// Example discovered_fields for 2FA -[{ name: 'otp', type: 'code' }] -``` - -## Complete Example - - -```typescript TypeScript -import Kernel from '@onkernel/sdk'; -const kernel = new Kernel(); + if (event.fields?.length) { + const fieldValues: Record = {}; -// Create connection -const auth = await kernel.auth.connections.create({ - domain: 'github.com', - profile_name: 'github-profile', -}); - -const login = await kernel.auth.connections.login(auth.id); + for (const field of event.fields) { + fieldValues[field.id] = await promptUser(field); + } -// One SSE stream handles login + 2FA -const events = await kernel.auth.connections.follow(auth.id); -let finalState; + await kernel.auth.connections.submit(auth.id, { + interaction_id: event.interaction_id, + field_values: fieldValues, + }); + } else if (event.choices?.length) { + const choice = await promptUserToChoose(event.choices); -for await (const event of events) { - if (event.event !== 'managed_auth_state') continue; - finalState = event; - - if (event.flow_step === 'AWAITING_INPUT' && event.discovered_fields?.length) { - // Check what fields are needed - const fieldNames = event.discovered_fields.map(f => f.name); - - if (fieldNames.includes('username')) { - // Initial login - await kernel.auth.connections.submit(auth.id, { - fields: { username: 'my-username', password: 'my-password' } - }); - } else { - // 2FA or additional fields - const code = await promptUserForCode(); - await kernel.auth.connections.submit(auth.id, { - fields: { [event.discovered_fields[0].name]: code } - }); - } + await kernel.auth.connections.submit(auth.id, { + interaction_id: event.interaction_id, + selected_choice_id: choice.id, + }); } } - -if (finalState?.flow_status === 'SUCCESS') { - console.log('Authentication successful!'); - - const browser = await kernel.browsers.create({ - profile: { name: 'github-profile' }, - stealth: true, - }); - - // Navigate to the site—you're already logged in - await page.goto('https://github.com'); -} ``` -```python Python -from kernel import AsyncKernel - -kernel = AsyncKernel() - -# Create connection -auth = await kernel.auth.connections.create( - domain="github.com", - profile_name="github-profile", -) - -login = await kernel.auth.connections.login(auth.id) - -# One SSE stream handles login + 2FA -events = await kernel.auth.connections.follow(auth.id) -final_state = None - -async for event in events: - if event.event != "managed_auth_state": - continue - final_state = event - - if event.flow_step == "AWAITING_INPUT" and event.discovered_fields: - # Check what fields are needed - field_names = [field.name for field in event.discovered_fields] - - if "username" in field_names: - # Initial login - await kernel.auth.connections.submit( - auth.id, - fields={"username": "my-username", "password": "my-password"}, - ) - else: - # 2FA or additional fields - code = input("Enter code: ") - await kernel.auth.connections.submit( - auth.id, - fields={event.discovered_fields[0].name: code}, - ) - -if final_state and final_state.flow_status == "SUCCESS": - print("Authentication successful!") - - browser = await kernel.browsers.create( - profile={"name": "github-profile"}, - stealth=True, - ) - - # Navigate to the site—you're already logged in - await page.goto("https://github.com") -``` - -```go Go -package main - -import ( - "context" - "fmt" - - "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/shared" -) - -func promptUserForCode() string { - return "123456" -} - -func main() { - ctx := context.Background() - client := kernel.NewClient() - - // Create connection - auth, err := client.Auth.Connections.New(ctx, kernel.AuthConnectionNewParams{ - ManagedAuthCreateRequest: kernel.ManagedAuthCreateRequestParam{ - Domain: "github.com", - ProfileName: "github-profile", - }, - }) - if err != nil { - panic(err) - } - - login, err := client.Auth.Connections.Login(ctx, auth.ID, kernel.AuthConnectionLoginParams{}) - if err != nil { - panic(err) - } - _ = login - - // One SSE stream handles login + 2FA - events := client.Auth.Connections.FollowStreaming(ctx, auth.ID) - authenticated := false - - for events.Next() { - event := events.Current() - if event.Event != "managed_auth_state" { - continue - } - if event.FlowStatus == "SUCCESS" { - authenticated = true - } - - if event.FlowStep == "AWAITING_INPUT" && len(event.DiscoveredFields) > 0 { - // Check what fields are needed - fieldNames := map[string]bool{} - for _, field := range event.DiscoveredFields { - fieldNames[field.Name] = true - } - - if fieldNames["username"] { - // Initial login - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - Fields: map[string]string{ - "username": "my-username", - "password": "my-password", - }, - }, - }) - if err != nil { - panic(err) - } - } else { - // 2FA or additional fields - code := promptUserForCode() - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - Fields: map[string]string{ - event.DiscoveredFields[0].Name: code, - }, - }, - }) - if err != nil { - panic(err) - } - } - } - } - if err := events.Err(); err != nil { - panic(err) - } - - if authenticated { - fmt.Println("Authentication successful!") - - browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ - Profile: shared.BrowserProfileParam{ - Name: kernel.String("github-profile"), - }, - Stealth: kernel.Bool(true), - }) - if err != nil { - panic(err) - } - - // Navigate to the site—you're already logged in - _, err = client.Browsers.Playwright.Execute(ctx, browser.SessionID, kernel.BrowserPlaywrightExecuteParams{ - Code: `await page.goto("https://github.com");`, - }) - if err != nil { - panic(err) - } - } -} -``` - +`promptUser` and `promptUserToChoose` represent your application's ui. The submission examples below show the same requests in each sdk. -This example covers username/password login with 2FA — the most common flow. If the site uses SSO, MFA selection, account pickers, or external actions (push notifications), see [Handling Different Input Types](#handling-different-input-types) below for how to handle each case. +every programmatic login session also has a `hosted_url`. redirect the user there if you want the hosted ui to finish an unexpected state. - -Every programmatic login session also has a `hosted_url`. If your flow encounters an unexpected state, you can redirect the user to this URL to complete login via the [Hosted UI](/auth/hosted-ui) instead. - +In the examples below, `state` is the current `managed_auth_state` event. -## Handling Different Input Types +## Fields -The SSE event loop handles `discovered_fields`, but login pages can require other input types too. In the examples below, `state` is the current `managed_auth_state` event from the stream. +Each field includes: -### SSO Buttons +| Property | Meaning | +|---|---| +| `id` | Stable id used as the key in `field_values`. | +| `ref` | Credential meaning, such as `email`, `password`, or `sms_code`. | +| `type` | `identifier`, `password`, `code`, `totp_code`, `totp_secret`, or `text`. | +| `label` | Text to show beside the input. | +| `reason` | `missing` or `rejected`. | +| `hint` | Optional context, including a masked code destination. | -When the login page has "Sign in with Google/GitHub/Microsoft" buttons, they appear in `pending_sso_buttons`: +Submit values by **field id**, not by `ref`: ```typescript TypeScript -if (state.pending_sso_buttons?.length) { - // Show the user available SSO options - for (const btn of state.pending_sso_buttons) { - console.log(`${btn.provider}: ${btn.label}`); - } - - // Submit the selected SSO button - await kernel.auth.connections.submit(auth.id, { - sso_button_selector: state.pending_sso_buttons[0].selector - }); -} +await kernel.auth.connections.submit(auth.id, { + interaction_id: state.interaction_id, + field_values: { + [state.fields[0].id]: userValue, + }, +}); ``` ```python Python -if state.pending_sso_buttons: - # Show the user available SSO options - for btn in state.pending_sso_buttons: - print(f"{btn['provider']}: {btn['label']}") - - # Submit the selected SSO button - await kernel.auth.connections.submit( - auth.id, - sso_button_selector=state.pending_sso_buttons[0]["selector"], - ) +await kernel.auth.connections.submit( + auth.id, + interaction_id=state.interaction_id, + field_values={state.fields[0].id: user_value}, +) ``` ```go Go -if len(state.PendingSSOButtons) > 0 { - // Show the user available SSO options - for _, btn := range state.PendingSSOButtons { - fmt.Printf("%s: %s\n", btn.Provider, btn.Label) - } - - // Submit the selected SSO button - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - SSOButtonSelector: kernel.String(state.PendingSSOButtons[0].Selector), +_, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ + SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ + InteractionID: kernel.String(state.InteractionID), + FieldValues: map[string]string{ + state.Fields[0].ID: userValue, }, - }) - if err != nil { - panic(err) - } + }, +}) +if err != nil { + panic(err) } ``` - -Common SSO provider domains (Google, Microsoft, Okta, Auth0, GitHub, etc.) are automatically allowed. For custom OAuth providers, add their domains to `allowed_domains` on the connection. - - -### SSO Provider Selection +### Replacing a rejected credential -As an alternative to clicking an SSO button by selector, you can submit the SSO provider name directly. When SSO buttons are detected, the session state includes a `sso_provider` field (a string) identifying the provider that Kernel recommends. You can also specify a provider explicitly using the `sso_provider` submit parameter: +A field with `reason: 'rejected'` means the site explicitly refused the previous value. Prompt for a new value and submit it against the new interaction: ```typescript TypeScript -if (state.pending_sso_buttons?.length) { - // Submit by provider name instead of selector - await kernel.auth.connections.submit(auth.id, { - sso_provider: state.pending_sso_buttons[0].provider // e.g., "google" - }); -} -``` - -```python Python -if state.pending_sso_buttons: - # Submit by provider name instead of selector - await kernel.auth.connections.submit( - auth.id, - sso_provider=state.pending_sso_buttons[0]["provider"], # e.g., "google" - ) -``` - -```go Go -if len(state.PendingSSOButtons) > 0 { - // Submit by provider name instead of selector - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - SSOProvider: kernel.String(state.PendingSSOButtons[0].Provider), // e.g., "google" - }, - }) - if err != nil { - panic(err) - } -} -``` - - - -`sso_provider` is a singular string value, not an array. Use `sso_button_selector` when you need to click a specific button by its CSS selector, and `sso_provider` when you want to identify the provider by name (e.g., `"google"`, `"microsoft"`, `"okta"`). - +const rejected = state.fields?.filter(field => field.reason === 'rejected'); -### MFA Selection +if (state.interaction_id && rejected?.length) { + const corrected = await promptUser(rejected); + const fieldValues = Object.fromEntries( + rejected.map(field => [field.id, corrected[field.id]]) + ); -When the site offers multiple MFA methods, they appear in `mfa_options`: - - -```typescript TypeScript -if (state.mfa_options?.length) { - // Available types: sms, email, totp, push, call, password, switch - for (const opt of state.mfa_options) { - console.log(`${opt.type}: ${opt.label}`); - } - - // Submit the selected MFA method await kernel.auth.connections.submit(auth.id, { - mfa_option_id: 'sms' + interaction_id: state.interaction_id, + field_values: fieldValues, }); } ``` ```python Python -if state.mfa_options: - # Available types: sms, email, totp, push, call, password, switch - for opt in state.mfa_options: - print(f"{opt['type']}: {opt['label']}") +rejected = [field for field in (state.fields or []) if field.reason == "rejected"] - # Submit the selected MFA method +if state.interaction_id and rejected: + corrected = await prompt_user(rejected) + field_values = { + field.id: corrected[field.id] + for field in rejected + } await kernel.auth.connections.submit( auth.id, - mfa_option_id="sms", + interaction_id=state.interaction_id, + field_values=field_values, ) ``` - -```go Go -if len(state.MfaOptions) > 0 { - // Available types: sms, email, totp, push, call, password, switch - for _, opt := range state.MfaOptions { - fmt.Printf("%s: %s\n", opt.Type, opt.Label) - } - - // Submit the selected MFA method - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - MfaOptionID: kernel.String("sms"), - }, - }) - if err != nil { - panic(err) - } -} -``` -After selecting an MFA method, keep listening for `discovered_fields` to submit the code, or handle external actions for push/security key. +An unattended reauth run does not ask for a corrected credential. It fails with `credentials_invalid` rather than repeating a value the site already rejected. + +## Choices - -The `switch` type represents generic method-switcher links like "Use another method" or "Try another way" that don't name a specific factor. Submit it the same way as any other MFA option to reveal the underlying alternatives on the next page. - +All selectable auth routes use the same shape. `choice.type` identifies the category: -### Sign-In Options (Account/Org Pickers) +- `mfa_method` +- `sso_provider` +- `sign_in_method` +- `auth_method` +- `identifier_method` +- `account` +- `other` -Some sites present non-MFA choices during login, such as account selection or organization pickers. These appear in `sign_in_options` as an array of objects with `id`, `label`, and optional `description`: +Render the visible `label`, optional `description`, and optional `masked_destination`. Submit the exact `choice.id` returned by the event: ```typescript TypeScript -if (state.sign_in_options?.length) { - // Show available options to the user - for (const opt of state.sign_in_options) { - console.log(`${opt.id}: ${opt.label}`); - if (opt.description) console.log(` ${opt.description}`); - } +const mfaChoices = state.choices?.filter(choice => choice.type === 'mfa_method') ?? []; +const selected = await choose(mfaChoices); - // Submit the selected option - await kernel.auth.connections.submit(auth.id, { - sign_in_option_id: state.sign_in_options[0].id - }); -} +await kernel.auth.connections.submit(auth.id, { + interaction_id: state.interaction_id, + selected_choice_id: selected.id, +}); ``` ```python Python -if state.sign_in_options: - # Show available options to the user - for opt in state.sign_in_options: - print(f"{opt['id']}: {opt['label']}") - if opt.get("description"): - print(f" {opt['description']}") - - # Submit the selected option - await kernel.auth.connections.submit( - auth.id, - sign_in_option_id=state.sign_in_options[0]["id"], - ) +mfa_choices = [choice for choice in (state.choices or []) if choice.type == "mfa_method"] +selected = await choose(mfa_choices) + +await kernel.auth.connections.submit( + auth.id, + interaction_id=state.interaction_id, + selected_choice_id=selected.id, +) ``` ```go Go -if len(state.SignInOptions) > 0 { - // Show available options to the user - for _, opt := range state.SignInOptions { - fmt.Printf("%s: %s\n", opt.ID, opt.Label) - if opt.Description != "" { - fmt.Println(" " + opt.Description) - } - } - - // Submit the selected option - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - SignInOptionID: kernel.String(state.SignInOptions[0].ID), - }, - }) - if err != nil { - panic(err) - } +_, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ + SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ + InteractionID: kernel.String(state.InteractionID), + SelectedChoiceID: kernel.String(selected.ID), + }, +}) +if err != nil { + panic(err) } ``` - -Sign-in options are distinct from MFA options. MFA options (`mfa_options`) represent second-factor authentication methods like SMS or TOTP. Sign-in options represent non-security choices like "Which account do you want to use?" or "Select your organization." - +Do not derive the submitted id from the label or mfa type. Two sms choices can have different masked destinations and different grounded targets. -### External Actions (Push, Security Key) +### Account and organization pickers -When the site requires an action outside the browser (push notification, security key tap), the step becomes `AWAITING_EXTERNAL_ACTION`: +Account and organization rows are choices with `type: 'account'` or another non-mfa choice type. Show every returned row and submit the selected stable id: - -```typescript TypeScript -if (state.flow_step === 'AWAITING_EXTERNAL_ACTION') { - // Show the message to the user - console.log(state.external_action_message); - // e.g., "Check your phone for a push notification" - - // Some sites offer fallback methods alongside the external action - // (e.g. "Try another way"). Submit one to switch verification methods. - if (state.mfa_options?.length) { - await kernel.auth.connections.submit(auth.id, { - mfa_option_id: state.mfa_options[0].type, - }); - } +```typescript +const accounts = state.choices?.filter(choice => choice.type === 'account'); - // Otherwise keep listening—the flow resumes automatically when the user completes the action +for (const account of accounts ?? []) { + console.log(account.label, account.description); } ``` -```python Python -if state.flow_step == "AWAITING_EXTERNAL_ACTION": - # Show the message to the user - print(state.external_action_message) - # e.g., "Check your phone for a push notification" - - # Some sites offer fallback methods alongside the external action - # (e.g. "Try another way"). Submit one to switch verification methods. - if state.mfa_options: - await kernel.auth.connections.submit( - auth.id, - mfa_option_id=state.mfa_options[0]["type"], - ) - - # Otherwise keep listening—the flow resumes automatically when the user completes the action -``` +Stored credential values are not returned for matching. Use only the masked or display context present on each choice. -```go Go -if state.FlowStep == kernel.ManagedAuthFlowStepAwaitingExternalAction { - // Show the message to the user - fmt.Println(state.ExternalActionMessage) - // e.g., "Check your phone for a push notification" - - // Some sites offer fallback methods alongside the external action - // (e.g. "Try another way"). Submit one to switch verification methods. - if len(state.MfaOptions) > 0 { - _, err := client.Auth.Connections.Submit(ctx, auth.ID, kernel.AuthConnectionSubmitParams{ - SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ - MfaOptionID: kernel.String(state.MfaOptions[0].Type), - }, - }) - if err != nil { - panic(err) - } - } - - // Otherwise keep listening—the flow resumes automatically when the user completes the action -} -``` - +## External actions - -`mfa_options`, `pending_sso_buttons`, and `sign_in_options` may be populated during `AWAITING_EXTERNAL_ACTION` when the site exposes fallback methods alongside the external action (for example, "Try another way" on a push prompt). Submit one of them to switch verification methods, or keep listening to let the user complete the external action. - +When `flow_step` is `AWAITING_EXTERNAL_ACTION`, show `external_action_message` and keep listening. The flow resumes when the external action completes. -## Step Reference +Some external-action screens also expose fallback `fields` or `choices`. If `interaction_id` is present, submit a fallback through the same canonical contract: -The `flow_step` field indicates what the flow is waiting for: +```typescript +if ( + state.flow_step === 'AWAITING_EXTERNAL_ACTION' && + state.interaction_id && + state.choices?.length +) { + const fallback = await choose(state.choices); + await kernel.auth.connections.submit(auth.id, { + interaction_id: state.interaction_id, + selected_choice_id: fallback.id, + }); +} +``` -| Step | Description | -|------|-------------| -| `DISCOVERING` | Finding the login page and analyzing it | -| `AWAITING_INPUT` | Waiting for field values, SSO button click, SSO provider selection, MFA selection, or sign-in option selection | -| `SUBMITTING` | Processing submitted values | -| `AWAITING_EXTERNAL_ACTION` | Waiting for push approval, security key, etc. | -| `COMPLETED` | Flow has finished | +## Step reference -## Status Reference +| Step | Description | +|---|---| +| `DISCOVERING` | Finding and inspecting the login surface. | +| `AWAITING_INPUT` | Waiting for canonical fields or choices. | +| `AWAITING_EXTERNAL_ACTION` | Waiting for an out-of-browser action; canonical fallbacks may also be present. | +| `SUBMITTING` | Processing the submitted interaction. | +| `COMPLETED` | The flow has finished. | -The `flow_status` field indicates the current flow state: +## Status reference | Status | Description | -|--------|-------------| -| `IN_PROGRESS` | Authentication is ongoing—keep listening | -| `SUCCESS` | Login completed, profile saved | -| `FAILED` | Login failed (check `error_message`) | -| `EXPIRED` | Flow timed out (10 minutes for user input, 20 minutes overall) | -| `CANCELED` | Flow was canceled | - -The `status` field indicates the overall connection state: +|---|---| +| `IN_PROGRESS` | Authentication is ongoing. | +| `SUCCESS` | Login completed and the profile was saved. | +| `FAILED` | Login failed; inspect `error_code` and `error_message`. | +| `EXPIRED` | The flow timed out. | +| `CANCELED` | The flow was canceled or superseded. | -| Status | Description | -|--------|-------------| -| `AUTHENTICATED` | Profile is logged in and ready to use | -| `NEEDS_AUTH` | Profile needs authentication | +The connection's overall `status` is `AUTHENTICATED` or `NEEDS_AUTH`. -## Connection Configuration +## Connection configuration -Connection-level options — custom login URL, SSO/OAuth, custom proxy, session recording, post-login URL, and updates — apply equally to all integration flows and are documented in [Connection Configuration](/auth/configuration). +Connection-level options such as a custom login url, allowed domains, proxy, session recording, and health-check interval apply to both hosted and programmatic flows. See [connection configuration](/auth/configuration). ## SSE stream behavior -`auth.connections.follow()` opens the Server-Sent Events stream at: +`auth.connections.follow()` opens: ``` GET /auth/connections/{id}/events ``` -The stream delivers `managed_auth_state` events containing `flow_status`, `flow_step`, `discovered_fields`, and the other login-flow fields used throughout this guide. It closes automatically when the flow succeeds, fails, expires, or is canceled. - - -Use the SSE stream for login flows. It delivers state changes immediately and avoids repeated status requests. - +The stream delivers `managed_auth_state` events and closes when the flow succeeds, fails, expires, or is canceled. Prefer the stream over polling so your ui receives each interaction id in order.