@kcrz/klaud
v0.3.0
Published
Give an application a login through a Klaud group, in one browser handoff
Readme
@kcrz/klaud
Give an application a login through a Klaud.me group, in one browser handoff.
klaud is a CLI for developers and AI coding agents. It connects a local application to a Klaud
group, provisions OpenID Connect credentials, and writes them to your environment file without
anyone copying a secret between browser tabs.
Requires Bun 1.3 or newer.
bunx @kcrz/klaud connect --access link --jsonHow sign-in fits together
Klaud is a standard OpenID Connect provider.
| Setting | Value |
| --- | --- |
| Issuer | https://klaud.me (the KLAUD_ISSUER that connect writes) |
| Discovery | https://klaud.me/.well-known/openid-configuration |
| Callback in a Better Auth app | <origin>/api/auth/callback/klaud |
Better Auth genericOAuth needs nothing but the issuer:
import { genericOAuth } from 'better-auth/plugins'
genericOAuth({
config: [
{
providerId: 'klaud',
discoveryUrl: `${process.env.KLAUD_ISSUER}/.well-known/openid-configuration`,
clientId: process.env.KLAUD_CLIENT_ID!,
clientSecret: process.env.KLAUD_CLIENT_SECRET!,
scopes: ['openid', 'profile', 'email'],
},
],
})On Cloudflare Workers (and other isolate runtimes), list the endpoints instead of discoveryUrl.
Discovery fetches while Better Auth initializes, and a request that waits on another request's
fetch never gets a response there, so every /api/auth call hangs:
genericOAuth({
config: [
{
providerId: 'klaud',
authorizationUrl: `${process.env.KLAUD_ISSUER}/api/auth/oauth2/authorize`,
tokenUrl: `${process.env.KLAUD_ISSUER}/api/auth/oauth2/token`,
userInfoUrl: `${process.env.KLAUD_ISSUER}/api/auth/oauth2/userinfo`,
clientId: process.env.KLAUD_CLIENT_ID!,
clientSecret: process.env.KLAUD_CLIENT_SECRET!,
scopes: ['openid', 'profile', 'email'],
},
],
})Commands
klaud connect
Creates a group and an application in Klaud after one approval in the browser, then writes the credentials.
klaud connect [options]| Option | Meaning |
| --- | --- |
| --app-name <name> | Application name. Defaults to the directory name. |
| --group-name <name> | Group name. Defaults to the application name. |
| --access <mode> | Who gets in: link, telegram, discord, patreon, github, gitlab. |
| --redirect <url> | Redirect URL to register. Repeatable, so local and production go in together. Defaults to http://localhost:<port>/api/auth/callback/klaud, with the port read from scripts.dev in package.json (--port, -p or PORT=), else 3000. |
| --env-file <path> | Where credentials go. Defaults to .env. |
| --json | Machine output, see below. |
What it does:
- Prints the redirect URLs it will register and where the port came from (
checking). - Prints the approval link (
authorization_required) and waits for the browser (waiting_for_user). The link is only printed; open it yourself or hand it to the person approving. - Writes
KLAUD_ISSUER,KLAUD_CLIENT_IDandKLAUD_CLIENT_SECRETto the env file. A key that already holds a value stops the command (env_conflict) instead of being overwritten. - Writes a version 2 binding and the group field schema to
.klaud/app.json. It holds no secret and can be committed. Version 1 bindings continue to be read. - Stores a registration token for this application in
~/.klaud/credentials.json(mode0600).add-url,remove-url,doctorandclaimsuse it; it expires after a month.
Running connect again in a bound project with its secret still in place does nothing and reports
linked. The client secret is handed over exactly once and cannot be fetched again.
klaud add-url <url>
Registers one more redirect URL, for example after a deploy. Registering an existing URL again is a no-op.
klaud add-url https://my-app.example.com/api/auth/callback/klaudklaud remove-url <url>
Removes a redirect URL the application no longer serves, for example the old host after a move.
Removing a URL that is not registered is a no-op. The last URL cannot be removed
(last_redirect_url): add the new one first.
klaud remove-url https://old-host.example.com/api/auth/callback/klaudklaud status
Prints what this directory is bound to (bound), or failed with not_bound.
klaud claims
Fetches the current group field schema using the stored registration token, updates .klaud/app.json
and prints the schema. The terminal claims event lists field names in added, removed and
changed; these arrays are empty when the schema is unchanged. A failed request leaves the file intact.
bunx @kcrz/klaud claims --jsonThe schema contains name, label, type, required, options, description and filledBy.
It contains no member answers or secrets. filledBy: "member" means the participant answers;
provider:… means the membership source supplies the answer, with participant input as a fallback.
klaud doctor
Checks that sign-in is wired correctly, one link of the chain per event:
| Check | Passes when |
| --- | --- |
| binding | .klaud/app.json exists and is readable. |
| env | The env file sets KLAUD_ISSUER, KLAUD_CLIENT_ID, KLAUD_CLIENT_SECRET. Only presence is checked; values are never printed. |
| discovery | ${KLAUD_ISSUER}/.well-known/openid-configuration answers and its issuer equals KLAUD_ISSUER. |
| client | Klaud knows the application, and its client id equals KLAUD_CLIENT_ID. |
| redirect_urls | At least one redirect URL is registered. The event lists them and says whether the local dev callback is among them (localRedirectRegistered); a missing local URL is reported with an add-url hint but does not fail the run. |
klaud doctor [--env-file .env] [--json]The run ends in healthy, or in failed carrying the code of the first failed check.
Machine output (--json)
Every command accepts --json. Then stdout carries exactly one JSON object per line and nothing
else; without it, human lines go to stderr. Every run ends in a terminal event, and a failure always
exits non-zero.
| status | Terminal | Fields |
| --- | --- | --- |
| checking | no | message?, redirectUrls? |
| authorization_required | no | url |
| waiting_for_user | no | — |
| check | no | check, ok, code?, message, nextAction?, missing?, redirectUrls?, localRedirectUrl?, localRedirectRegistered? |
| linked | yes (connect) | appName, groupName, clientId, envFile, claimsScope |
| url_added | yes (add-url) | url, redirectUrls |
| url_removed | yes (remove-url) | url, redirectUrls |
| bound | yes (status) | applicationId, groupId, issuer |
| claims | yes (claims) | applicationId, claims, added, removed, changed |
| healthy | yes (doctor) | applicationId, clientId, issuer, redirectUrls |
| failed | yes | code, message, nextAction?: { command } |
{"status":"checking","message":"Redirect URL uses port 3200 from scripts.dev in package.json; pass --redirect to choose another.","redirectUrls":["http://localhost:3200/api/auth/callback/klaud"]}
{"status":"authorization_required","url":"https://klaud.me/setup/…"}
{"status":"waiting_for_user"}
{"status":"linked","appName":"my-app","groupName":"my-app","clientId":"…","envFile":".env"}Failure codes include not_bound, token_missing, unauthorized, last_redirect_url, env_conflict, already_bound,
declined_by_user, session_expired, rate_limited, access_not_configured, invalid_request,
unknown_command, network, internal_error, and from doctor: env_missing,
discovery_unreachable, issuer_mismatch, client_id_mismatch, no_redirect_urls.
Secrets — the client secret, the registration token, the poll secret — never appear in any output.
Receiving group claims in Better Auth
Your application must request the claims scope. connect writes credentials and the schema;
it does not choose OAuth scopes for the application. With the scope, both userinfo and the ID token
contain klaud: { group: { id, name }, claims: { … } }. Without it, the response is unchanged.
Group fields named email or name stay inside klaud.claims.
import { genericOAuth } from 'better-auth/plugins'
const issuer = process.env.KLAUD_ISSUER!
genericOAuth({
config: [{
providerId: 'klaud',
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
clientId: process.env.KLAUD_CLIENT_ID!,
clientSecret: process.env.KLAUD_CLIENT_SECRET!,
scopes: ['openid', 'profile', 'email', 'claims'],
async getUserInfo(tokens) {
const discovery = await fetch(`${issuer}/.well-known/openid-configuration`)
.then((response) => response.json())
const response = await fetch(discovery.userinfo_endpoint, {
headers: { authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) throw new Error('Klaud userinfo request failed')
const profile = await response.json()
// Read profile.klaud?.claims.city when building your application profile. Persisting custom fields
// in Better Auth requires declaring those fields in user.additionalFields.
return {
id: profile.sub,
name: profile.name,
email: profile.email,
emailVerified: profile.email_verified,
image: profile.picture,
klaud: profile.klaud,
}
},
}],
})getUserInfo receives the typed answers from Klaud; add mapProfileToUser and configured
additional user fields if the application needs to persist them. Optional unanswered fields are absent.
Environment variables
| Variable | Meaning | Default |
| --- | --- | --- |
| KLAUD_URL | Control plane URL | https://klaud.me |
License
MIT
