Authentication
A client signs in one of six ways, chosen by its auth option. Every way except token and session ends in
the same thing: a short-lived Caitlyn access token, which the client caches, renews before it expires and sends
as Authorization: Bearer <token>. You never handle that token yourself.
| Mode | Use it for | You give it |
|---|---|---|
token |
Scripts you run yourself, and trying things out | A personal access token |
clientCredentials |
A service or integration acting as itself | An API client's ID and secret |
oauth |
An app acting as the person using it | An API client's ID (and secret, on a server), a redirect URI |
awsIam |
A workload on AWS (Lambda, ECS, AgentCore), with no stored secret | Nothing: the workload's own AWS credentials |
workloadIdentity |
A CI job, or another workload with an OpenID Connect token | A function returning the token |
session |
The Caitlyn app itself, and tools driving a local copy of it | The app's session cookie |
Every token is for one workspace: its audience is https://<workspace>/api, the workspace the client was created
for, and every other workspace refuses it.
A personal access token
Section titled “A personal access token”A personal access token acts as you, with your permissions, until it expires or you revoke it. It is sent as it is and never renewed. Use it for your own scripts and for trying the API; never put one in code other people run.
const script = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'token', token: process.env.CAITLYN_TOKEN! }, // a personal access token, cait-pat-…});script = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=Token(os.environ["CAITLYN_TOKEN"]), # a personal access token, cait-pat-…)script, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.Token(os.Getenv("CAITLYN_TOKEN"))) // a personal access token, cait-pat-…let script = Client::new("your-company.getcaitlyn.ai", Auth::token(std::env::var("CAITLYN_TOKEN")?))?;A service: client credentials
Section titled “A service: client credentials”A registered API client signs in with its ID and secret and acts as itself, with the scopes it was given. The client asks for a new token when the old one is due; there is no refresh token. Keep the secret in a secret store, never in code.
const service = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'clientCredentials', clientId: process.env.CAITLYN_CLIENT_ID!, clientSecret: process.env.CAITLYN_CLIENT_SECRET!, scopes: ['api:read'], },});service = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=ClientCredentials( client_id=os.environ["CAITLYN_CLIENT_ID"], client_secret=os.environ["CAITLYN_CLIENT_SECRET"], scopes=["api:read"], ),)service, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.ClientCredentials{ ClientID: os.Getenv("CAITLYN_CLIENT_ID"), ClientSecret: os.Getenv("CAITLYN_CLIENT_SECRET"), Scopes: []string{"api:read"},})let service = Client::new( "your-company.getcaitlyn.ai", Auth::client_credentials(std::env::var("CAITLYN_CLIENT_ID")?, std::env::var("CAITLYN_CLIENT_SECRET")?) .scopes(["api:read"]),)?;A person: OAuth
Section titled “A person: OAuth”An app signs a person in with the OAuth authorisation code flow and PKCE, then acts as that person. The client
builds the sign-in address; you send the person there and keep the state and the PKCE verifier until Caitlyn
sends them back to your redirect URI.
const asPerson = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'oauth', clientId: process.env.CAITLYN_CLIENT_ID!, clientSecret: process.env.CAITLYN_CLIENT_SECRET, // only on a server; a browser app has none redirectUri: 'https://app.example.com/caitlyn/callback', scopes: ['api:write'], tokenStore: encryptedTokenStoreFor(userId), // { load, save, clear }; default: in memory },});
// Send the person to `url`, and keep `state` and `codeVerifier` for the callbackconst { url, state, codeVerifier } = await asPerson.auth.authorizationUrl();as_person = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=OAuth( client_id=os.environ["CAITLYN_CLIENT_ID"], client_secret=os.environ.get("CAITLYN_CLIENT_SECRET"), # a confidential client on a server redirect_uri="https://app.example.com/caitlyn/callback", scopes=["api:write"], token_store=encrypted_token_store_for(user_id), # load, save, clear; default: in memory ),)
# Send the person to start.url, and keep start.state and start.code_verifier for the callbackstart = as_person.auth.authorization_url()asPerson, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.OAuth{ ClientID: os.Getenv("CAITLYN_CLIENT_ID"), ClientSecret: os.Getenv("CAITLYN_CLIENT_SECRET"), // a confidential client on a server RedirectURI: "https://app.example.com/caitlyn/callback", Scopes: []string{"api:write"}, TokenStore: encryptedTokenStoreFor(userID), // default: in memory})if err != nil { return err}
// Send the person to start.URL, and keep start.State and start.CodeVerifier for the callbackstart, err := asPerson.Auth().AuthorizationURL(ctx, caitlyn.AuthorizationOptions{})let as_person = Client::new( "your-company.getcaitlyn.ai", Auth::oauth(std::env::var("CAITLYN_CLIENT_ID")?) .client_secret(std::env::var("CAITLYN_CLIENT_SECRET")?) // a confidential client on a server .redirect_uri("https://app.example.com/caitlyn/callback") .scopes(["api:write"]) .token_store(Arc::new(MemoryTokenStore::new(None))), // your own encrypted store on a server)?;
// Send the person to start.url, and keep start.state and start.code_verifier for the callbacklet start = as_person.auth().authorization_url(None, None, None)?;Then, on the redirect URI, hand the client the code. It checks the state, exchanges the code and saves the tokens to the token store:
declare const code: string;declare const returnedState: string;await asPerson.auth.handleCallback({ code, state: returnedState, expectedState: state, codeVerifier });as_person.auth.handle_callback( code=code, state=returned_state, expected_state=start.state, code_verifier=start.code_verifier,)_, err = asPerson.Auth().HandleCallback(ctx, caitlyn.Callback{ Code: code, State: returnedState, ExpectedState: start.State, CodeVerifier: start.CodeVerifier,})as_person .auth() .handle_callback(Callback { code, state: Some(returned_state), expected_state: Some(start.state), code_verifier: start.code_verifier, ..Default::default() }) .await?;- Refresh is automatic. Refresh tokens are single use: each refresh returns a new one, which the client saves
to the token store. A refresh token the server refuses fails the call with the sign-in error
invalid_grantand clears the store: sign the person in again. - The token store keeps the tokens between processes. On a server, keep them encrypted, one store per person. Without one, they live in memory for the life of the client.
- Signing out (
auth.signOut(), Pythonauth.sign_out(), GoAuth().SignOut, Rustauth().sign_out()) revokes the refresh token and clears the store. - In a browser, use
oauthwithout a client secret. Sign in from a browser covers both patterns, and what the TypeScript SDK refuses in a page.
A workload on AWS: awsIam
Section titled “A workload on AWS: awsIam”Not live yet. The SDKs have this mode, but Caitlyn does not accept workload sign-in yet.
A workload on AWS signs in with its own IAM role: no secret is stored anywhere. The client asks AWS STS
(GetWebIdentityToken, on the regional endpoint) for a token AWS signs, and exchanges it for a Caitlyn token.
The SDKs sign that one request themselves, so no AWS SDK is needed.
const worker = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'awsIam', region: 'ap-southeast-2' }, // the workload's own AWS credentials});worker = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=AwsIam(region="ap-southeast-2"), # the workload's own AWS credentials)worker, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.AWSIAM{Region: "ap-southeast-2"}) // the workload's own AWS credentialslet worker = Client::new("your-company.getcaitlyn.ai", Auth::aws_iam().region("ap-southeast-2"))?;- Credentials come from
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY(as Lambda sets them), then the container credentials endpoint (ECS, Fargate, AgentCore). For anything else, such as profiles or single sign-on, pass a credentials provider. This is the only mode that readsAWS_*variables. - The region is the option, then
AWS_REGION, thenAWS_DEFAULT_REGION. - Setting up: outbound identity federation must be turned on in the AWS account, and the workspace needs an API client that trusts the account's issuer and the role.
A CI job or other workload: workloadIdentity
Section titled “A CI job or other workload: workloadIdentity”Not live yet. The SDKs have this mode, but Caitlyn does not accept workload sign-in yet.
A workload with an OpenID Connect token from an issuer the workspace trusts, such as a GitLab CI job's
id_tokens, exchanges that token for a Caitlyn one. The function you give is called at every sign-in, because
Caitlyn accepts each token once, only for the exact audience https://<workspace>/api, and only before it
expires.
publish: id_tokens: CAITLYN_ID_TOKEN: aud: https://your-company.getcaitlyn.ai/apiconst ciJob = new Caitlyn({ workspace: 'your-company.getcaitlyn.ai', auth: { mode: 'workloadIdentity', token: () => process.env.CAITLYN_ID_TOKEN! }, // GitLab CI id_tokens});ci_job = Caitlyn( workspace="your-company.getcaitlyn.ai", auth=WorkloadIdentity(token=lambda: os.environ["CAITLYN_ID_TOKEN"]), # GitLab CI id_tokens)ciJob, err := caitlyn.NewClient("your-company.getcaitlyn.ai", caitlyn.WorkloadIdentity{ Token: func(context.Context) (string, error) { return os.Getenv("CAITLYN_ID_TOKEN"), nil }, // GitLab CI id_tokens})let ci_job = Client::new("your-company.getcaitlyn.ai", WorkloadIdentity::from_env("CAITLYN_ID_TOKEN"))?;The Caitlyn app: session
Section titled “The Caitlyn app: session”session sends the Caitlyn app's own session cookie instead of a token, and an Origin header on every write.
It is for the Caitlyn app itself and for tools that drive a local copy of it; an integration uses one of the
other modes.
const localApp = new Caitlyn({ workspace: 'caitlyn.lndo.site', auth: { mode: 'session', cookie: sessionCookie, origin: 'https://caitlyn.lndo.site' },});local_app = Caitlyn( workspace="caitlyn.lndo.site", auth=Session(cookie=session_cookie, origin="https://caitlyn.lndo.site"),)localApp, err := caitlyn.NewClient("caitlyn.lndo.site", caitlyn.Session{Cookie: sessionCookie, Origin: "https://caitlyn.lndo.site"})let local_app = Client::new("caitlyn.lndo.site", Auth::session(session_cookie).origin("https://caitlyn.lndo.site"))?;When a token is refused
Section titled “When a token is refused”- One token, renewed early. A client keeps one access token and renews it a minute before it expires (or halfway through a life shorter than two minutes). However many calls need a new token at once, the client makes one request for it, and they all wait for that.
- One more try after a 401. In the
clientCredentials,oauth,awsIamandworkloadIdentitymodes, a 401 makes the client drop its token, get a new one and send the call once more, whatever the operation. A second 401 is raised as theUNAUTHENTICATEDerror. Intokenandsessionmode a 401 is raised at once. - Sign-in errors are a separate class from the API's errors:
CaitlynAuthErrorin TypeScript and Python,*caitlyn.AuthErrorin Go,Error::Authin Rust.codeis the OAuth error (invalid_client,invalid_grant), or one of the SDK's own:sign_in_required(oauthwith no tokens yet),invalid_state(the callback's state does not match),invalid_request(a missing redirect URI or region),aws_identity_failed(awsIamcould not get its AWS token), and in a browsernot_allowed_in_browser.

