Skip to content

Auth script contract

A script auth provider runs your JavaScript in the engine’s sandboxed QuickJS runtime to obtain a token. This page is the exact contract.

async function authenticate({ env, config, secret, fetch, sender }) {
// …
return { token, expiresIn };
}

The function must be named authenticate, must be async, and takes a single object.

NameTypeWhat it is
envRecord<string, string>The active environment’s variables — e.g. env.baseUrl
configRecord<string, string>The provider’s non-secret config map
secret(name) => stringRead a secret by keychain name. Throws if missing
fetchasync (url, opts?) => ResponseHTTP fetch — method, headers, body in; status, ok, headers, text(), json() out
sender{ id, label, tenant, secretRef?, vars } | nullThe selected sender to authenticate as, or null if the provider has none
FieldTypeRequiredNotes
tokenstringyesThe credential, injected per the provider’s inject block
expiresInnumbernoLifetime in seconds

Return expiresIn whenever the server tells you one. It is how the cache knows when to refetch; without it, Tinspec cannot know the token went stale and you will see avoidable 401s.

async function authenticate({ env, config, secret, fetch, sender }) {
const res = await fetch(`${env.baseUrl}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: config.clientId,
client_secret: secret("users-api.CLIENT_SECRET"),
username: sender.id,
password: secret(sender.secretRef),
}).toString(),
});
if (!res.ok) {
throw new Error(`login failed: ${res.status} ${await res.text()}`);
}
const json = await res.json();
return { token: json.access_token, expiresIn: json.expires_in };
}

sender is null when the provider has no senders. Handle both cases if your provider might be used either way:

const user = sender?.id ?? config.defaultUser;
const password = secret(sender?.secretRef ?? "users-api.DEFAULT_PASSWORD");

A sender’s non-secret vars are available as sender.vars, so per-identity values like a tenant id or a role need no extra plumbing.

Throw. The message surfaces in the app and on the send that triggered the resolution. Be specific — login failed: 401 with the body text is far more useful six months later than a bare failure.

A secret(...) call for a name that does not exist on this machine throws on its own, which is usually the right behaviour: secrets are per-machine by design and never sync, so a teammate who pulls your project must set their own.

The runtime is QuickJS, embedded in the engine. It gives you standard JavaScript plus the five arguments above.

What it does not give you:

  • No filesystem, no process, no environment variables beyond env.
  • No network except the provided fetch.
  • No npm modules, no import, no require.
  • No access to the keychain beyond secret(name) for names the provider declares.

URLSearchParams, JSON, btoa/atob-style encoding, and the usual language built-ins are available; assume the standard library and nothing host-specific.

kind: script providers reference the file by workspace-relative path:

authProviders:
- name: legacy-sso
kind: script
script: .tinspec/auth/legacy-sso.js
config:
clientId: test-harness
secretRefs:
- users-api.CLIENT_SECRET
senders:
- id: admin@example.com
secretRef: users-api.admin.PASSWORD

It is committed with the project, so it reviews like any other code — which is the point. An auth flow that lives in a script somebody pasted into a GUI is a flow nobody else can fix.

A chain’s script node uses the same sandbox with a different entry point — async function run(vars, ctx), where the keys of the returned object become output variables. See Build a chain.