OAuth for member apps
PKCE, authorize URL, exchanging the code, refreshing and revoking tokens with the SDK
Member apps and user linking get their tokens through OAuth 2.1 with PKCE. The flow and all its rules are described in Authentication; this page shows how to implement it with the SDK. The example uses Express; the pattern fits any framework.
Redirect to the consent screen
import { buildAuthorizeUrl, createPkcePair, createState } from "@checkcourt/sdk";
const CLIENT_ID = process.env.CHECKCOURT_CLIENT_ID!;
const CLIENT_SECRET = process.env.CHECKCOURT_CLIENT_SECRET!;
const REDIRECT_URI = "https://calendar.example.com/callback";
app.get("/connect", async (req, res) => {
const pkce = await createPkcePair();
const state = createState();
req.session.oauth = { verifier: pkce.verifier, state };
res.redirect(
buildAuthorizeUrl({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
state,
codeChallenge: pkce.challenge,
// prompt: "consent", // force the dialog, e.g. to connect another club
// tenant: "…", // preselect a club
}),
);
});createPkcePair creates a 43 character verifier and its S256 challenge.
Exchange the code for tokens
import { exchangeCode, parseAuthorizeCallback } from "@checkcourt/sdk";
app.get("/callback", async (req, res) => {
const { verifier, state } = req.session.oauth;
// checks state; throws OAuthError if CheckCourt sent an error instead (e.g. access_denied)
const { code } = parseAuthorizeCallback(new URL(req.originalUrl, REDIRECT_URI), state);
const tokens = await exchangeCode({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
code,
redirectUri: REDIRECT_URI,
codeVerifier: verifier,
});
await db.tokens.put(req.user.id, tokens);
res.redirect("/connected");
});tokens looks like this:
{
accessToken: "ccu_…",
refreshToken: "ccr_…",
expiresAt: 1790003600000, // epoch milliseconds
scope: ["bookings:read", "teams:read"],
installations: [{ installationId: "inst_…", tenantId: "…" }],
}The code is valid for 60 seconds and only once.
Use the tokens
Pass the stored tokens to userAuth, see
Client and authentication. The SDK
then refreshes them and calls onTokens with every new set.
Refreshing yourself
If you need a refresh without the client, for example in a background job:
import { refreshTokens, TokenRevokedError, AppTemporarilyUnavailableError } from "@checkcourt/sdk";
try {
const next = await refreshTokens({ clientId, clientSecret, refreshToken: stored.refreshToken });
await db.tokens.put(memberId, next); // persist right away, the old refresh token is spent
} catch (err) {
if (err instanceof AppTemporarilyUnavailableError) {
// connection paused: the token stays valid, retry later with the same one
} else if (err instanceof TokenRevokedError) {
// dead: delete the tokens, ask the member to connect again
} else {
throw err;
}
}Never run two refreshes with the same refresh token at the same time. CheckCourt treats that as reuse and revokes every token from that consent.
Revoking
import { revokeToken } from "@checkcourt/sdk";
await revokeToken({ clientId, clientSecret, token: stored.refreshToken });A refresh token takes its whole chain with it. The connection itself stays; only the member or the club can end it.
Functions
| Function | Returns |
|---|---|
createPkcePair() | Promise<{ verifier, challenge, method: "S256" }> |
pkceChallenge(verifier) | Promise<string>, the S256 challenge for your own verifier |
createState() | A random state |
buildAuthorizeUrl({ clientId, redirectUri, state, codeChallenge, prompt?, tenant?, baseUrl? }) | The redirect URL |
parseAuthorizeCallback(url, expectedState) | { code } or an OAuthError |
exchangeCode({ clientId, clientSecret, code, redirectUri, codeVerifier }) | Promise<UserTokenSet> |
refreshTokens({ clientId, clientSecret, refreshToken }) | Promise<UserTokenSet> |
revokeToken({ clientId, clientSecret, token }) | Promise<void> |
fetchInstallationToken({ clientId, clientSecret, installationId }) | Promise<InstallationToken> |
All functions send the client ID and secret via HTTP Basic and accept optional baseUrl
and fetch.