Provider interaction lifecycle

Build guided Credential acquisition with redirects, forms, device instructions, callbacks, polling, and secure completion.

An interactive CredentialProvider does not implement an OAuth-shaped API. It implements a small state machine through interaction.start and interaction.advance. The provider chooses the next action; Constal durably coordinates the session, renders the action, validates the response, and stores the final Credential.

OAuth authorization code, device authorization, API-key enrollment, approval workflows, installation pickers, and asynchronous secret issuance all use the same contract.

The protocol

interaction.start(request, context) receives the Credential identity, non-secret configuration, a signed state value, the shared callback URL, session expiry, and a platform-generated proof challenge. It returns an action and optional provider-private state.

interaction.advance(request, context) receives the next validated event, the same session evidence, the proof verifier, and the encrypted private state returned by the previous step. It returns another action or completes the Credential.

Provider actionConsole behaviorNext event
redirectOpens an allowlisted HTTPS URLcallback
formRenders the supplied JSON Schemasubmit
displayShows instructions, a code, and an optional linkcontinue
wait with callbackWaits for external ingresscallback
wait with pollPolls no sooner than pollAfterMspoll
completeEncrypts, verifies, and activates materialTerminal

Actions are data, not executable UI. This keeps provider packages portable across the Console, CLI, SDKs, and future clients.

Inline acquisition

A Resource setup flow may request a Credential from a named provider without giving the Resource permission to mint it. For example, a Gateway package declares credentialSlots.account.provider: "example-device". If no compatible Credential exists, the Console can start that provider's interaction, save the completed Credential, bind its CRN into the still-uncommitted Gateway form, and resume installation. Cancellation returns to the preserved Resource form without creating the Resource.

This is client orchestration over the same Credential APIs. Ownership remains separate: the Credential Provider controls acquisition and lifecycle, the Credential coordinator stores versions, and the Resource receives only an authorized binding at invocation time.

Minimal interactive provider

ts
import { credentialProvider } from "@constal/sdk";

export default credentialProvider({
  id: "example-device",
  version: "1.0.0",
  displayName: "Example device connection",
  description: "Connects an Example account through device authorization.",
  credentialSlots: ["client-secret"],
  configSchema: {
    type: "object",
    required: ["clientId"],
    additionalProperties: false,
    properties: { clientId: { type: "string", title: "Client ID" } }
  },
  credentialConfigSchema: { type: "object", additionalProperties: false },
  rotation: {
    mode: "auto", intervalMs: null, overlapMs: 60000,
    maxAgeMs: null, refreshBeforeMs: 300000
  },
  egress: { rules: [{
    operations: ["interaction.start", "interaction.advance", "mint"],
    origin: "https://api.example.com", pathPrefix: "/oauth/", methods: ["POST"]
  }] },
  interaction: {
    origins: ["https://example.com"],
    async start(request) {
      const grant = await beginDeviceGrant(request);
      return {
        action: {
          kind: "display",
          title: "Connect your Example account",
          instructions: "Open Example and enter this one-time code.",
          userCode: grant.userCode,
          verificationUrl: "https://example.com/device",
          continueLabel: "I approved it"
        },
        privateState: JSON.stringify({ deviceCode: grant.deviceCode })
      };
    },
    async advance(request) {
      const state = JSON.parse(request.privateState ?? "{}");
      const token = await exchangeDeviceCode(state.deviceCode);
      if (token.pending) return {
        action: {
          kind: "wait", resume: "poll",
          pollAfterMs: 5000, expiresAt: request.expiresAt
        },
        privateState: request.privateState
      };
      return {
        action: {
          kind: "complete", material: token.accessToken,
          expiresAt: token.expiresAt
        },
        privateState: token.refreshToken
      };
    }
  },
  mintRecovery: { kind: "outcome-unknown" },
  async mint(request) {
    return refreshAccessToken(request.privateState);
  }
});

The example abbreviates external API helpers. Those calls must use context.egress() and the declared operation allowlist.

Callbacks

Interactive providers share one callback:

text
https://platform.constal.ai/v1/credential-interactions/callback

Register it exactly when the external service redirects or posts back to Constal. Signed, expiring state identifies the Credential and interaction session; the callback URL does not contain a tenant identifier. The platform accepts bounded GET query callbacks and bounded POST bodies, strips authority-bearing headers, verifies state, and delivers one callback event to the pinned provider operation.

For a redirect action, the URL must be HTTPS, use one of the provider's exact declared origins, and contain the exact request.state value. A display.verificationUrl is subject to the same origin allowlist.

Forms and instructions

A form action supplies a supported object JSON Schema with additionalProperties: false. Constal renders it and validates the submitted value before provider code sees it. Titles, descriptions, defaults, examples, enums, and bounds provide the same low-friction controls used during provider setup.

Use display when the operator must act elsewhere. Instructions and codes are presentation values only; never put access or refresh material in them.

Session ownership and recovery

The Credential coordinator owns:

  • authenticated principal and optional customer ownership;
  • signed state, expiry, proof verifier, and callback routing;
  • encrypted action and provider-private state;
  • event/action matching, schema validation, and poll rate limits;
  • replay fencing, immutable provider operation pins, verification, and activation.

Provider code owns protocol-specific requests and the decision about what comes next. It cannot choose another Credential, callback destination, executable UI, or undeclared network origin.

interaction.start is idempotent. interaction.advance uses outcome-unknown recovery because a callback code, device code, approval, or refresh token may be consumed exactly once. The invocation journal resumes the same transition; it must never manufacture a second external exchange.

Security checklist

  • Keep installation secrets in declared bootstrap Credential slots.
  • Keep per-Credential inputs non-secret and schema bounded.
  • Use the platform state and proof values exactly; do not generate substitutes.
  • Return temporary protocol state only as privateState.
  • Never log codes, verifiers, material, or private state.
  • Give every wait step an expiry and a conservative poll interval.
  • Verify material before activation when the service offers a verification endpoint.
  • Implement refresh and source-side destruction when the external authority supports them.

For OAuth specifically, continue with Authorize an OAuth credential and OAuth callback security.