Authentication overview
How repo-sdk consumes provider credentials and surfaces auth failures.
repo-sdk consumes tokens only — it never runs an OAuth flow or mints credentials for you. You pass
the provider factory a ready-made token (or a token provider), and the SDK attaches it to every request.
When a credential is missing or invalid, calls fail with a RepoError whose code is unauthorized.
The token-only model
repo-sdk deliberately does not implement OAuth authorization flows, token exchange, or refresh. It
takes a credential you already hold and attaches it to every request:
- No OAuth dance. You obtain the token however you like — a personal access token in an env var, an
OAuth token from your own auth layer, or a
tokenProvidercallback that mints short-lived tokens. - No token minting. The SDK never obtains a credential on its own. The one exception is GitHub App auth, where the SDK mints and caches installation access tokens internally from your app credentials.
- You re-authenticate. With a static token, an expired or revoked credential makes the next call
throw a
RepoErrorwithcode: 'unauthorized'— catch it, obtain a fresh token, and construct a new client. With atokenProvider, refresh is handled for you (see below).
import { createClient, RepoError } from 'repo-sdk';
import { github } from 'repo-sdk/github';
try {
const client = createClient({ provider: github({ auth: { token } }) });
await client.repos.get({ repo: 'capawesome-team/repo-sdk' });
} catch (error) {
if (error instanceof RepoError && error.code === 'unauthorized') {
// Token is missing, expired, or revoked — obtain a new one and retry.
}
}
Expiring tokens: tokenProvider
Every provider also accepts auth: { tokenProvider } — an async callback the SDK invokes on every
request to obtain the current bearer token. It’s built for OAuth-style expiring credentials: cache
inside the callback and return the cached token while it’s valid.
import type { TokenProvider } from 'repo-sdk';
const tokenProvider: TokenProvider = async ({ forceRefresh }) => {
if (forceRefresh || cachedTokenExpired()) {
return refreshAndCacheToken();
}
return cachedToken();
};
const client = createClient({ provider: github({ auth: { tokenProvider } }) });
When a request comes back 401, the SDK re-invokes the callback once with forceRefresh: true and
retries that request a single time. forceRefresh means the previous token was rejected — bypass
your cache and mint a fresh token, otherwise the retry replays the same stale credential and the
call fails with unauthorized. There is no second retry.
Two caveats:
repos.getCloneUrlembeds the current token in the returned URL (orheaderson Azure DevOps with Entra auth), so the URL stops working when that token expires — fetch a fresh one per use.- Gitea sends a
tokenProvidertoken with theBearerscheme (OAuth2 tokens), while a static{ token }uses Gitea’stokenscheme for personal access tokens.
Who is this token?
users.me() resolves the account behind the configured credentials — a stable provider user id for
storing the connection, plus display fields for a “connected as” UI:
const me = await client.users.me();
me.id; // stable provider user id
me.username; // login / handle
me.name; // display name, when the provider exposes one
me.email; // when the provider exposes one — see "Resolving a hidden email"
me.avatarUrl;
It is gated by the userProfile capability: credentials without a user identity — e.g. GitHub App
installation tokens — throw unsupported. Branch on client.capabilities.userProfile before calling
it when the token type isn’t known up front.
Resolving a hidden email
Two providers omit the email from the profile endpoint: GitHub hides it for private-email accounts
(the default for new accounts), and Bitbucket never returns one there. Pass includeEmail: true to
have those two make at most one extra request (/user/emails) and return the primary verified
address:
const me = await client.users.me({ includeEmail: true });
- Scopes: GitHub needs
user:email(classic PATs / OAuth) or the “Email addresses” read permission (fine-grained PATs); Bitbucket needs theemailscope. - A missing scope does not throw. The emails call’s
forbiddenerror is swallowed andemailstays unset — the email is enrichment, not a precondition. Any other failure (rate limit, network error) propagates as usual. - GitLab, Azure DevOps, and Gitea already return the email on the profile call, so the flag is a documented no-op there. On GitHub the extra request is also skipped when the profile already carries a public email.
Self-hosted and enterprise instances
Four of the five providers accept a baseUrl so you can target a self-managed deployment. Point it at
your instance’s API root and everything else works unchanged:
| Provider | Self-hosted product | baseUrl example |
|---|---|---|
| GitHub | Enterprise Server (GHES) | https://ghe.example.com/api/v3 |
| GitLab | Self-managed | https://gitlab.example.com/api/v4 |
| Azure DevOps | Azure DevOps Server | https://azure.example.com/tfs |
| Gitea | Self-hosted / Forgejo | https://gitea.example.com/api/v1 |
| Bitbucket | — (Cloud only) | not supported |
Per-provider guides
GitHub
Token (PAT / OAuth) and GitHub App auth, single-installation auto-detect, GHES.
GitLab
PAT / project / group / OAuth tokens and self-managed instances.
Bitbucket Cloud
Atlassian API token vs. access token, and the archive-download caveat.
Azure DevOps
PAT vs. Entra ID token provider, the required organization, and Server.
Gitea
Personal access token, self-hosted instances, and Forgejo.