Add a credential provider
Write a CredentialProvider for Nango, Composio or a cloud secret manager, route refs to more than one provider, and check it with the conformance kit.
Blocks resolve every credential_ref through one CredentialProvider
(see Credentials). Vault and Vercel Connect
ship with better-supabase; anything else is a provider you write against the
same versioned contract. The provider stays in your app or its own
package: better-supabase never depends on Nango, Composio or a cloud SDK.
The contract
| Member | Required | Does |
|---|---|---|
apiVersion: 1, name | yes | the contract version and the provider value of the refs it serves |
capabilities(ref) | yes | userSubjects, authorization, revoke and inbound for the ref |
getToken(ref, { subject, scopes }) | yes | the token, its expiry and the headers that send it |
revoke(ref, { subject }) | yes | forgets the credential; false when there was none |
startAuthorization, completeAuthorization | when capabilities(ref).authorization | sends a user to connect an account and finishes the callback |
verifyInbound(request, ref) | when capabilities(ref).inbound | checks a webhook the third party sent |
set(ref, value, { subject, description }) | no | stores or replaces a secret the app hands over, such as an API key |
Every method returns an AsyncResult and never throws for a provider
error. Use these error kinds so blocks can react to them:
| Situation | Kind |
|---|---|
| A ref for another provider, or malformed | invalid_input |
| No credential stored for the subject | not_found |
| The user hasn't connected the account yet | forbidden |
| The provider's API failed | unexpected |
Keep the token out of errors, logs and events: only the value getToken
returns carries it.
A cloud secret manager
A secret manager holds app credentials. This sketch reads AWS Secrets Manager, with one secret per ref and, for per-user refs, one per user:
import {
DeleteSecretCommand,
GetSecretValueCommand,
SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
import { AsyncResult, dbError, err, ok } from "better-supabase";
import type {
CredentialProvider,
CredentialRef,
CredentialSubject,
} from "better-supabase/credentials";
const client = new SecretsManagerClient({});
function secretId(ref: CredentialRef, subject: CredentialSubject) {
if (typeof ref.secret !== "string") return undefined;
const perUser = ref.scope === "user";
if (perUser && subject.type !== "user") return undefined;
return perUser && subject.type === "user"
? `${ref.secret}/${subject.id}`
: ref.secret;
}
export const awsCredentials: CredentialProvider = {
apiVersion: 1,
name: "aws-secrets",
capabilities: (ref) => ({
userSubjects: ref.scope === "user",
authorization: false,
revoke: true,
inbound: false,
}),
getToken(ref, { subject, signal }) {
const id =
ref.provider === "aws-secrets" ? secretId(ref, subject) : undefined;
if (id === undefined) {
return AsyncResult.err(
dbError("invalid_input", "Not an aws-secrets ref"),
);
}
return AsyncResult.from(async () => {
try {
const out = await client.send(
new GetSecretValueCommand({ SecretId: id }),
{ abortSignal: signal },
);
const token = out.SecretString ?? "";
return ok({ token, headers: { authorization: `Bearer ${token}` } });
} catch (cause) {
return cause instanceof Error &&
cause.name === "ResourceNotFoundException"
? err(dbError("not_found", "No secret for this ref"))
: err(dbError("unexpected", "Secrets Manager failed"));
}
});
},
revoke(ref, { subject }) {
const id =
ref.provider === "aws-secrets" ? secretId(ref, subject) : undefined;
if (id === undefined) {
return AsyncResult.err(
dbError("invalid_input", "Not an aws-secrets ref"),
);
}
return AsyncResult.from(async () => {
await client.send(
new DeleteSecretCommand({
SecretId: id,
ForceDeleteWithoutRecovery: true,
}),
);
return ok(true);
});
},
};Google Secret Manager, Azure Key Vault and HashiCorp Vault follow the same
shape: a ref names the secret, getToken reads its latest version, and
revoke deletes it or disables the version. Cache tokens for a short time
when the API is slow; clear the cache in revoke.
Nango
Nango holds OAuth connections that users create, and
refreshes their tokens. A ref names the integration
({ "provider": "nango", "integration": "github" }), and the Nango
connection id is derived from the subject, so each user gets their own
connection:
| Method | Nango call |
|---|---|
getToken | read the connection for the integration and connection id; return its access token |
startAuthorization | create a Connect session for the end user and return its connect link as url |
completeAuthorization | nothing to exchange: Nango finishes the OAuth flow; confirm that the connection exists |
revoke | delete the connection |
verifyInbound | check the X-Nango-Hmac-Sha256 signature of Nango's webhooks |
Return forbidden with the hint CREDENTIAL_AUTHORIZATION_REQUIRED when
the user has no connection yet, so callers know to start authorization.
Load @nangohq/node lazily or call Nango's HTTP API with fetch, and keep
the Nango secret key in the server's env.
Composio
Composio keeps connected accounts per user and
integration. Map the subject to Composio's user id and the ref to an auth
config ({ "provider": "composio", "authConfig": "ac_github" }):
| Method | Composio call |
|---|---|
getToken | read the user's active connected account; return its access token |
startAuthorization | initiate a connection request and return its redirect URL as url |
revoke | delete the connected account |
When you call tools through Composio itself instead of with the token,
keep the connected account id in the row's credential_ref and let the
provider hand the id back as the token. The contract stays the same.
More than one provider
createServer takes one credentials value. credentialRouter from
better-supabase/credentials serves several providers as one: each call goes
to the provider whose name matches the ref's provider field.
import {
credentialRouter,
vaultCredentials,
} from "better-supabase/credentials";
export const credentials = credentialRouter([
vaultCredentials({ transport }),
nangoCredentials,
awsCredentials,
]);A ref no provider serves fails with invalid_input
(CREDENTIAL_PROVIDER_UNKNOWN), and an optional method the chosen provider
lacks, such as set on an OAuth-only provider, fails with unsupported.
Two providers with the same name throw when you build the router.
Check it
Run testCredentialProvider from better-supabase/testing against a test
account or a local emulator. It checks the API version, refuses a ref for
another provider, reads a seeded token and a changed one, keeps per-user
credentials apart, revokes, and, with inbound, accepts a signed request
and refuses a tampered one:
import { testCredentialProvider } from "better-supabase/testing";
import { it } from "vitest";
it("conforms", () =>
testCredentialProvider(awsCredentials, {
ref: { provider: "aws-secrets", secret: "conformance" },
userRef: { provider: "aws-secrets", secret: "conformance", scope: "user" },
seed: (ref, subject, value) => putSecret(ref, subject, value),
}));A table with a credential_ref column also needs a lifecycle step that
calls revoke when its row goes away, for example when a tenant is
deleted. revokeIfConfigured(provider, ref, { subject, tenant }) does
that for code where the provider is optional: it resolves false without
a provider, when the provider can't revoke the ref, or when the ref belongs
to another tenant.
Last updated on
Credentials
Third-party tokens behind a credential reference, resolved by a CredentialProvider over Supabase Vault or Vercel Connect.
Add another SDK
The converter, stream and engine contracts an adapter for TanStack AI, LangChain, the OpenAI SDK or Temporal implements to use the AI and workflow blocks.