Skip to main content
POST
/
v1
/
auth
/
exchange
Exchange Login Code
curl --request POST \
  --url https://api.example.com/v1/auth/exchange \
  --header 'Content-Type: application/json' \
  --data '
{
  "loginCode": "<string>",
  "codeVerifier": "<string>"
}
'
{
  "accessToken": "<string>",
  "refreshToken": "<string>",
  "expiresInSec": 123,
  "profile": {
    "email": "<string>",
    "slackUserId": "<string>",
    "slackTeamId": "<string>"
  }
}
Exchanges a login code (received after successful Slack OAuth authorization) for a JWT access token and refresh token pair. This endpoint validates the PKCE code verifier and issues authenticated tokens.

Endpoint

POST /v1/auth/exchange

Rate Limits

  • 20 requests per minute per IP address

Request Body

loginCode
string
required
Login code received in the CLI callback URL after successful Slack authorization.Format: 32-byte base64url-encoded string (43 characters)Validation: Minimum 10 charactersSource: Extracted from the code query parameter in the callback redirectExpiration: Must be used before the OAuth session expires (10 minutes from /auth/start)
codeVerifier
string
required
Original PKCE code verifier generated before calling /auth/start.Validation: 43-128 characters (per OAuth 2.0 PKCE spec)Security: The API computes SHA256(codeVerifier) and compares it to the stored code challenge. Mismatches are rejected.

Response

accessToken
string
required
JWT access token for authenticating API requests.Format: JSON Web Token (JWT) signed with HS256TTL: Default 15 minutes (configurable via JWT_ACCESS_TTL_MINUTES)Usage: Include in Authorization: Bearer <accessToken> headerPayload:
{
  "sub": "user-uuid",
  "email": "[email protected]",
  "slackUserId": "U0123456789",
  "slackTeamId": "T0123456789",
  "iat": 1234567890,
  "exp": 1234568790
}
refreshToken
string
required
Opaque refresh token for obtaining new access tokens.Format: 48-byte base64url-encoded random string (64 characters)TTL: Default 30 days (configurable via REFRESH_TTL_DAYS)Storage: Store securely; required for POST /v1/auth/refreshSecurity: Single-use only - each refresh revokes this token and issues a new pair
expiresInSec
integer
required
Access token lifetime in seconds.Default: 900 (15 minutes)Usage: Calculate token expiration: Date.now() + expiresInSec * 1000
profile
object
required
Authenticated user profile information.

Example Request

curl -X POST https://api.rs-tunnel.example.com/v1/auth/exchange \
  -H "Content-Type: application/json" \
  -d '{
    "loginCode": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
    "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  }'

Example Response

200 Success
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NWM3YzQzZS1hMzJhLTRiZGEtOGU4Yi0zZjJjNmE5ZTEyM2UiLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwic2xhY2tVc2VySWQiOiJVMDEyMzQ1Njc4OSIsInNsYWNrVGVhbUlkIjoiVDAxMjM0NTY3ODkiLCJpYXQiOjE3MDkwNDAwMDAsImV4cCI6MTcwOTA0MDkwMH0.9x8fJ3kLmN5pQ7rS2tU4vW6xY8zA1bC3dE5fG7hI9jK",
  "refreshToken": "mB92K27uhbUJU1p1r_wW1gFWFOEjXk_dBjftJeZ4CVP-Ab3kDf9mPqRsTuVwXyZ",
  "expiresInSec": 900,
  "profile": {
    "email": "[email protected]",
    "slackUserId": "U0123456789",
    "slackTeamId": "T0123456789"
  }
}
400 Invalid Login Code
{
  "code": "INVALID_LOGIN_CODE",
  "message": "Login code is invalid."
}
400 PKCE Verification Failed
{
  "code": "INVALID_CODE_VERIFIER",
  "message": "PKCE code verifier mismatch."
}
400 Login Code Expired
{
  "code": "LOGIN_CODE_EXPIRED",
  "message": "Login code expired."
}
400 Login Code Already Used
{
  "code": "LOGIN_CODE_USED",
  "message": "Login code is no longer valid."
}

Error Codes

CodeHTTP StatusDescription
INVALID_LOGIN_CODE400Login code not found or missing user context
LOGIN_CODE_USED400Login code has already been exchanged
LOGIN_CODE_EXPIRED400OAuth session expired (>10 minutes)
INVALID_CODE_VERIFIER400PKCE code verifier does not match stored challenge
USER_NOT_FOUND404User record not found (internal error)
INVALID_INPUT400Request body validation failed
RATE_LIMIT_EXCEEDED429Too many requests within the time window

PKCE Verification

The API validates the PKCE code verifier by computing its SHA-256 hash and comparing it to the stored code challenge:
import { createHash } from 'node:crypto';

function createCodeChallenge(verifier: string): string {
  return createHash('sha256').update(verifier).digest('base64url');
}

// Server-side validation
const computedChallenge = createCodeChallenge(request.body.codeVerifier);
if (computedChallenge !== session.codeChallenge) {
  throw new Error('INVALID_CODE_VERIFIER');
}
This ensures that only the client that initiated the /auth/start request (and generated the original codeVerifier) can exchange the login code.

Security Notes

Single-Use Login Codes: Each login code can only be exchanged once. After successful exchange, the OAuth session is marked as “consumed” and cannot be reused.
Token Storage: Store tokens securely based on your platform:
  • CLI: Encrypted file in user’s home directory
  • Web: httpOnly cookies (preferred) or secure storage with XSS protection
  • Mobile: Platform keychain/keystore
Never store tokens in:
  • Local storage (web) - vulnerable to XSS
  • Unencrypted files
  • Version control
  • Logs or error messages

Complete Authentication Example

import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'node:http';

// Step 1: Generate PKCE pair
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
  .update(codeVerifier)
  .digest('base64url');

// Step 2: Start local callback server
const callbackUrl = 'http://127.0.0.1:56789/callback';
let loginCode: string;

const server = createServer((req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`);
  loginCode = url.searchParams.get('code')!;
  res.writeHead(200);
  res.end('Authentication successful! You may close this window.');
  server.close();
});

server.listen(56789);

// Step 3: Start OAuth flow
const startResponse = await fetch('https://api.rs-tunnel.example.com/v1/auth/start', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: '[email protected]',
    codeChallenge,
    cliCallbackUrl: callbackUrl,
  }),
});

const { authorizeUrl, state } = await startResponse.json();

// Step 4: Open browser (user authorizes via Slack)
console.log('Opening browser:', authorizeUrl);
open(authorizeUrl); // Use 'open' package or similar

// Step 5: Wait for callback (server receives loginCode)
await new Promise((resolve) => server.on('close', resolve));

// Step 6: Exchange login code for tokens
const exchangeResponse = await fetch('https://api.rs-tunnel.example.com/v1/auth/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    loginCode,
    codeVerifier,
  }),
});

const tokens = await exchangeResponse.json();
console.log('Authenticated as:', tokens.profile.email);

// Store tokens securely
fs.writeFileSync('~/.rs-tunnel/tokens.json', JSON.stringify(tokens), { mode: 0o600 });

Next Steps

Refresh Token

Obtain new access tokens before expiration

Create Tunnel

Use your access token to create a tunnel

Build docs developers (and LLMs) love