Skip to main content
reference 8 min read

Third-party app integration (Authorization Code + PKCE)

Act on behalf of we-fly users with the Authorization Code + PKCE flow, with consent, refresh, and revocation.

Use this flow when your integration acts on behalf of we-fly users who are not you — a competition platform, a coaching tool, a phone app. Each user passes through a consent screen on we-fly.cloud before your app gets a token.

Decision tree

  • Your app has a backend that can keep secrets? → register as a confidential client. PKCE is recommended but optional.
  • Your app is a single-page web app, mobile app, or desktop app that ships its bundle to users? → register as a public client. PKCE is required.

If you're not sure, pick public client + PKCE — it's the safer default and it works for everything except backend-only integrations.

Registering the application

  1. Sign in to https://we-fly.cloud.
  2. Open https://we-fly.cloud/developerNew application.
  3. Fill out:
    • Name and Description — both shown on the user consent screen. Make them recognisable.
    • Homepage URL — optional, shown on the consent screen as a trust signal.
    • Client typeconfidential or public.
    • Redirect URIs — every URL you'll ever redirect users back to. One per line. Must be https://, http://localhost (dev), or — for public clients only — a private-use app scheme such as com.example.app:/oauth/callback. See Native app redirects.
    • Grant types — tick authorization_code and refresh_token.
    • Allowed scopes — pick the union of everything any user might consent to. Individual flows can request a narrower subset.

You'll get a Client ID (wf_app_…). Confidential clients also get a Client secret (wf_secret_…) — store it in your backend, never ship it to the browser.

Native app redirects

A mobile app has no web server to receive the code on. RFC 8252 §7.1 solves this with a private-use URI scheme the OS routes back to your app, and we accept those for public clients:

cloud.we-fly.wetrack:/oauth/callback     ← what you register
com.example.app://oauth/callback         ← the // form works too

Rules we enforce at registration:

RuleWhy
The scheme must be a domain you control, in reverse (com.example.app) — at least two labelsRFC 8252 §7.1; also stops javascript:, data: and friends from ever being registered
Public clients onlyA confidential client has a server to redirect to. Any app on the device can claim a scheme, so handing a backend's codes to one would defeat the point of holding a secret
No fragment (#…)RFC 6749 §3.1.2
PKCE (S256) is mandatoryThe scheme is claimable, so the code alone must be useless — this is what makes the flow safe

The alternative, if you'd rather not rely on scheme claiming, is a claimed https URL — Universal Links (iOS) / App Links (Android) — which registers like any other https:// URI. That's the stronger option where you can serve the association file; the private-use scheme is the pragmatic one when you can't.

Loopback redirects (http://localhost:PORT/…, RFC 8252 §7.3) are also accepted, for desktop apps that can bind a port.

Flow

User                              Your app                       we-fly
 │
 │   1. User clicks "Connect we-fly"
 │ ─────────────────────────────► │
 │                                │
 │                                │   2. Generate code_verifier (random),
 │                                │      derive code_challenge = SHA256+base64url,
 │                                │      generate `state` (CSRF token).
 │                                │
 │                                │   3. Redirect user to /oauth/authorize
 │   ◄──────────────────────────  │
 │                                                                │
 │   4. Browser opens https://we-fly.cloud/oauth/authorize?       │
 │      response_type=code&                                       │
 │      client_id=…&                                              │
 │      redirect_uri=https://your-app.example/oauth/callback&     │
 │      scope=flights:read profile:read&                          │
 │      state=…&                                                  │
 │      code_challenge=…&                                         │
 │      code_challenge_method=S256                                │
 │ ────────────────────────────────────────────────────────────► │
 │                                                                │
 │   5. User signs in if needed, sees consent screen,             │
 │      clicks "Authorize".                                       │
 │                                                                │
 │   6. we-fly redirects back to your redirect_uri:               │
 │      ?code=wf_code_…&state=…                                   │
 │   ◄──────────────────────────────────────────────────────────  │
 │                                │                               │
 │   7. Your callback handler                                     │
 │      verifies `state`,                                         │
 │      exchanges code for tokens. ─────────────────────────────► │
 │                                                                │
 │   8. POST /api/oauth/token                                     │
 │      grant_type=authorization_code                             │
 │      code=wf_code_…                                            │
 │      redirect_uri=…                                            │
 │      code_verifier=…                                           │
 │      client_id=…&client_secret=…  (confidential only)          │
 │                                                                │
 │   9. Response:                                                 │
 │      { access_token, refresh_token, expires_in, scope, … }     │
 │                                                                │
 │  10. Call /api/v1/* with Authorization: Bearer …                │
 │                                                                │

Step 1 — generate PKCE values

// Browser-side or anywhere.
function base64url(buf) {
  return Buffer.from(buf)
    .toString("base64")
    .replace(/=+$/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
}

const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64url(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)),
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));

Store codeVerifier and state somewhere the callback handler can read them (server-side session or browser sessionStorage).

Step 2 — redirect to the authorization endpoint

https://we-fly.cloud/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  &scope=flights%3Aread+profile%3Aread
  &state=STATE_VALUE
  &code_challenge=CHALLENGE
  &code_challenge_method=S256

Parameters:

NameRequiredValue
response_typeyescode (no implicit flow)
client_idyesYour wf_app_…
redirect_uriyesMust exactly match one of the registered URIs
scopeyesSpace-separated; subset of your app's allowed scopes
staterecommendedOpaque CSRF token your app generates
code_challengerequired for public clientsBase64url SHA-256 of code_verifier
code_challenge_methodrequired when code_challenge is sentS256 (we don't accept plain)

Step 3 — handle the callback

we-fly redirects the user back to your redirect_uri with either:

  • Success: ?code=wf_code_…&state=…
  • Failure: ?error=access_denied&error_description=…&state=…

Verify state matches what you stored. If it doesn't, abort — the request is forged.

Step 4 — exchange the code

curl -X POST https://we-fly.cloud/api/oauth/token \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d "grant_type=authorization_code" \
  -d "code=${CODE}" \
  -d "redirect_uri=${REDIRECT_URI}" \
  -d "code_verifier=${CODE_VERIFIER}"

For public clients, omit -u and send client_id as a body parameter:

curl -X POST https://we-fly.cloud/api/oauth/token \
  -d "grant_type=authorization_code" \
  -d "client_id=${CLIENT_ID}" \
  -d "code=${CODE}" \
  -d "redirect_uri=${REDIRECT_URI}" \
  -d "code_verifier=${CODE_VERIFIER}"

Response:

{
  "access_token": "wf_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "wf_rt_…",
  "refresh_token_expires_in": 2592000,
  "scope": "flights:read profile:read"
}

Store the refresh token securely (encrypted at rest if it touches disk). The access token is short-lived; the refresh token is the sensitive material.

Refresh-token rotation

Access tokens last 1 hour. Refresh tokens last 30 days from issuance. When the access token expires, exchange the refresh:

curl -X POST https://we-fly.cloud/api/oauth/token \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=${REFRESH_TOKEN}"

Critical: every refresh issues a new refresh token. The old refresh token is invalid as of the moment of refresh — discard it. Storing or retrying with the old token will trigger our theft-detection branch and revoke every token in the chain for this user. If that happens, the user must re-authorise from scratch.

Write your refresh logic as a single atomic operation:

async function refresh(currentRefreshToken) {
  const res = await tokenEndpoint({
    grant_type: "refresh_token",
    refresh_token: currentRefreshToken,
  });
  if (!res.ok) {
    // Anything 4xx here, especially `invalid_grant`, means the
    // refresh was rejected. Force the user back through consent.
    return { kind: "reauth_required" };
  }
  const json = await res.json();
  await storage.replace({
    accessToken: json.access_token,
    refreshToken: json.refresh_token,           // <-- store the NEW one
    expiresAt: Date.now() + json.expires_in * 1000,
  });
  return { kind: "ok", accessToken: json.access_token };
}

Revocation

When the user disconnects your app, call /api/oauth/revoke:

curl -X POST https://we-fly.cloud/api/oauth/revoke \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -d "token=${REFRESH_TOKEN}" \
  -d "token_type_hint=refresh_token"

Per RFC 7009, the response is always 200 OK. The user can also revoke from https://we-fly.cloud/developer; once revoked, your tokens will return 401 invalid_token on the next API call — handle that by clearing local credentials and prompting re-auth.

Reference implementation (Next.js)

// app/oauth/start/route.ts
import { randomBytes, createHash } from "node:crypto";
import { redirect } from "next/navigation";

const CLIENT_ID = process.env.WEFLY_CLIENT_ID!;
const REDIRECT  = `${process.env.PUBLIC_URL}/oauth/callback`;

function base64url(buf: Buffer) {
  return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}

export async function GET() {
  const verifier  = base64url(randomBytes(32));
  const challenge = base64url(createHash("sha256").update(verifier).digest());
  const state     = base64url(randomBytes(16));

  // Persist verifier + state in an HTTP-only cookie or your session.
  await session.set("oauth.pkce", { verifier, state });

  const url = new URL("https://we-fly.cloud/oauth/authorize");
  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", CLIENT_ID);
  url.searchParams.set("redirect_uri", REDIRECT);
  url.searchParams.set("scope", "flights:read profile:read");
  url.searchParams.set("state", state);
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");
  redirect(url.toString());
}
// app/oauth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const code   = req.nextUrl.searchParams.get("code");
  const state  = req.nextUrl.searchParams.get("state");
  const error  = req.nextUrl.searchParams.get("error");
  if (error)  return new Response(`OAuth error: ${error}`, { status: 400 });
  if (!code)  return new Response("missing code", { status: 400 });

  const stored = await session.get("oauth.pkce");
  if (!stored || stored.state !== state) {
    return new Response("state mismatch", { status: 400 });
  }

  const basic = Buffer.from(
    `${process.env.WEFLY_CLIENT_ID}:${process.env.WEFLY_CLIENT_SECRET}`,
  ).toString("base64");
  const tokenRes = await fetch("https://we-fly.cloud/api/oauth/token", {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${process.env.PUBLIC_URL}/oauth/callback`,
      code_verifier: stored.verifier,
    }),
  });

  if (!tokenRes.ok) {
    const body = await tokenRes.json().catch(() => ({}));
    return new Response(JSON.stringify(body), { status: 400 });
  }
  const tokens = await tokenRes.json();
  // Persist tokens against the local user...
  await session.set("wefly", { ...tokens, obtainedAt: Date.now() });
  return NextResponse.redirect(new URL("/connected", req.url));
}

Common errors

You getWhy
invalid_grant on code exchangeCode expired (10 min) / already used / redirect_uri mismatch / code_verifier doesn't match code_challenge
invalid_grant on refreshRefresh token expired (30 days), revoked, or already rotated. Force re-auth.
invalid_scopeYou asked for a scope the app isn't registered for, or tried to widen on refresh
unauthorized_clientApp is suspended, or pending app tried to authorise someone other than its owner
access_denied (on callback URL)User clicked "Cancel" on the consent screen