Files
clinch/docs/oidc-refresh-tokens-client-guide.md

19 KiB

OIDC Refresh Tokens - Client Implementation Guide

Overview

Clinch now supports OAuth 2.0 Refresh Tokens, allowing your applications to maintain long-lived sessions without requiring users to re-authenticate every hour.

Key Benefits:

  • No user re-authentication for 30 days (configurable)
  • Silent token refresh - no redirects, no user interaction
  • Secure token rotation - prevents reuse attacks
  • Token revocation support - users can invalidate sessions

Quick Start

Before (Without Refresh Tokens)

User logs in → Access token (1 hour)
After 1 hour → Redirect to /oauth/authorize
User auto-approves → New access token
Repeat every hour... 😞

Now (With Refresh Tokens)

User logs in → Access token (1 hour) + Refresh token (30 days)
After 1 hour → POST to /oauth/token with refresh_token
Get new tokens → No redirect! No user interaction! 🎉

Initial Authorization

1. Authorization Code Flow (Unchanged)

Step 1: Redirect user to authorization endpoint

GET https://auth.example.com/oauth/authorize?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  response_type=code&
  scope=openid%20profile%20email&
  state=RANDOM_STATE&
  code_challenge=BASE64URL(SHA256(code_verifier))&
  code_challenge_method=S256

Step 2: Exchange authorization code for tokens

POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=CODE_VERIFIER

Response (NEW - now includes refresh_token):

{
  "access_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "eyJhbGc...",
  "refresh_token": "abc123xyz...",
  "scope": "openid profile email"
}

IMPORTANT: Store the refresh_token securely! You'll need it to get new access tokens.


Token Refresh Flow

When your access_token expires (after 1 hour), use the refresh_token to get new tokens without user interaction.

How to Refresh Tokens

Request:

POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=YOUR_REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET

Response:

{
  "access_token": "eyJhbGc...NEW",
  "token_type": "Bearer",
  "expires_in": 3600,
  "id_token": "eyJhbGc...NEW",
  "refresh_token": "def456uvw...NEW",
  "scope": "openid profile email"
}

CRITICAL:

  • The old refresh_token is immediately revoked (single-use)
  • You receive a new refresh_token to use next time
  • Replace the old refresh token with the new one in your storage

Token Lifecycle

┌─────────────────────────────────────────────────────────┐
│ Initial Authorization                                   │
├─────────────────────────────────────────────────────────┤
│ GET /oauth/authorize → User logs in                     │
│ POST /oauth/token (authorization_code grant)            │
│   ↓                                                      │
│ Receive: access_token (1h) + refresh_token (30d)        │
└─────────────────────────────────────────────────────────┘
                         │
                         ↓
┌─────────────────────────────────────────────────────────┐
│ Token Refresh (Silent, No User Interaction)            │
├─────────────────────────────────────────────────────────┤
│ After 1 hour (access_token expires):                    │
│ POST /oauth/token (refresh_token grant)                 │
│   ↓                                                      │
│ Receive: NEW access_token + NEW refresh_token           │
│ Old refresh_token is revoked                            │
└─────────────────────────────────────────────────────────┘
                         │
                         ↓ (Repeat for 30 days)
┌─────────────────────────────────────────────────────────┐
│ Session Expiry                                          │
├─────────────────────────────────────────────────────────┤
│ After 30 days (refresh_token expires):                  │
│ Redirect user to /oauth/authorize for re-authentication │
└─────────────────────────────────────────────────────────┘

Token Storage Best Practices

Secure Storage Recommendations

Web Applications (Server-Side):

  • Store refresh tokens in server-side session (encrypted)
  • Use HttpOnly, Secure cookies for access tokens
  • Never send refresh tokens to browser JavaScript

Single Page Applications (SPAs):

  • Store access tokens in memory only (JavaScript variable)
  • Store refresh tokens in HttpOnly, Secure cookie (via backend)
  • Use Backend-for-Frontend (BFF) pattern for refresh

Mobile Apps:

  • Use platform-specific secure storage:
    • iOS: Keychain
    • Android: EncryptedSharedPreferences or Keystore
  • Never store in UserDefaults/SharedPreferences

Desktop Apps:

  • Use OS-specific credential storage
  • Encrypt tokens at rest

DO NOT Store Refresh Tokens In:

  • LocalStorage (XSS vulnerable)
  • SessionStorage (XSS vulnerable)
  • Unencrypted cookies
  • Plain text files
  • Source code or config files

Token Revocation

Allow users to invalidate their sessions (e.g., "Sign out of all devices").

Revoke a Token

Request:

POST https://auth.example.com/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=YOUR_TOKEN
&token_type_hint=refresh_token
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET

Parameters:

  • token (required) - The token to revoke (access or refresh token)
  • token_type_hint (optional) - "access_token" or "refresh_token"
  • client_id + client_secret (required) - Client authentication

Response:

HTTP/1.1 200 OK

Note: Per RFC 7009, the response is always 200 OK, even if the token was invalid or already revoked (prevents token scanning attacks).


Error Handling

Refresh Token Errors

1. Invalid or Expired Refresh Token

{
  "error": "invalid_grant",
  "error_description": "Invalid refresh token"
}

Action: Redirect user to /oauth/authorize for re-authentication

2. Refresh Token Revoked (Reuse Detected!)

{
  "error": "invalid_grant",
  "error_description": "Refresh token has been revoked"
}

Action:

  • This indicates a security issue (possible token theft)
  • All tokens in the same family are revoked
  • Redirect user to /oauth/authorize
  • Consider alerting the user about suspicious activity

3. Invalid Client Credentials

{
  "error": "invalid_client"
}

Action: Check your client_id and client_secret


Implementation Examples

Example 1: Node.js Express

const axios = require('axios');

class OAuthClient {
  constructor(config) {
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
    this.tokenEndpoint = config.tokenEndpoint;
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = null;
  }

  // Exchange authorization code for tokens
  async exchangeCode(code, redirectUri, codeVerifier) {
    const response = await axios.post(this.tokenEndpoint, new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: redirectUri,
      client_id: this.clientId,
      client_secret: this.clientSecret,
      code_verifier: codeVerifier
    }));

    this.storeTokens(response.data);
    return response.data;
  }

  // Refresh access token
  async refreshAccessToken() {
    if (!this.refreshToken) {
      throw new Error('No refresh token available');
    }

    const response = await axios.post(this.tokenEndpoint, new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: this.refreshToken,
      client_id: this.clientId,
      client_secret: this.clientSecret
    }));

    this.storeTokens(response.data);
    return response.data;
  }

  // Get valid access token (auto-refresh if needed)
  async getAccessToken() {
    // Check if token is expired or about to expire (5 min buffer)
    if (this.expiresAt && Date.now() >= this.expiresAt - 300000) {
      await this.refreshAccessToken();
    }

    return this.accessToken;
  }

  storeTokens(tokenResponse) {
    this.accessToken = tokenResponse.access_token;
    this.refreshToken = tokenResponse.refresh_token;
    this.expiresAt = Date.now() + (tokenResponse.expires_in * 1000);
  }

  // Revoke tokens
  async revokeToken(token, tokenTypeHint) {
    await axios.post('https://auth.example.com/oauth/revoke', new URLSearchParams({
      token: token,
      token_type_hint: tokenTypeHint,
      client_id: this.clientId,
      client_secret: this.clientSecret
    }));
  }
}

// Usage
const client = new OAuthClient({
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
  tokenEndpoint: 'https://auth.example.com/oauth/token'
});

// After initial login
await client.exchangeCode(authCode, redirectUri, codeVerifier);

// Make API calls (auto-refreshes if needed)
const token = await client.getAccessToken();
const apiResponse = await axios.get('https://api.example.com/data', {
  headers: { Authorization: `Bearer ${token}` }
});

// Logout - revoke refresh token
await client.revokeToken(client.refreshToken, 'refresh_token');

Example 2: Python

import requests
import time
from urllib.parse import urlencode

class OAuthClient:
    def __init__(self, client_id, client_secret, token_endpoint):
        self.client_id = client_id
        self.client_secret = client_secret
        self.token_endpoint = token_endpoint
        self.access_token = None
        self.refresh_token = None
        self.expires_at = None

    def exchange_code(self, code, redirect_uri, code_verifier):
        """Exchange authorization code for tokens"""
        response = requests.post(self.token_endpoint, data={
            'grant_type': 'authorization_code',
            'code': code,
            'redirect_uri': redirect_uri,
            'client_id': self.client_id,
            'client_secret': self.client_secret,
            'code_verifier': code_verifier
        })
        response.raise_for_status()
        self._store_tokens(response.json())
        return response.json()

    def refresh_access_token(self):
        """Refresh the access token using refresh token"""
        if not self.refresh_token:
            raise ValueError('No refresh token available')

        response = requests.post(self.token_endpoint, data={
            'grant_type': 'refresh_token',
            'refresh_token': self.refresh_token,
            'client_id': self.client_id,
            'client_secret': self.client_secret
        })
        response.raise_for_status()
        self._store_tokens(response.json())
        return response.json()

    def get_access_token(self):
        """Get valid access token, refresh if needed"""
        # Check if token is expired (with 5 min buffer)
        if self.expires_at and time.time() >= self.expires_at - 300:
            self.refresh_access_token()

        return self.access_token

    def _store_tokens(self, token_response):
        """Store tokens and expiration time"""
        self.access_token = token_response['access_token']
        self.refresh_token = token_response['refresh_token']
        self.expires_at = time.time() + token_response['expires_in']

    def revoke_token(self, token, token_type_hint='refresh_token'):
        """Revoke a token"""
        requests.post('https://auth.example.com/oauth/revoke', data={
            'token': token,
            'token_type_hint': token_type_hint,
            'client_id': self.client_id,
            'client_secret': self.client_secret
        })

# Usage
client = OAuthClient(
    client_id='your-client-id',
    client_secret='your-client-secret',
    token_endpoint='https://auth.example.com/oauth/token'
)

# After initial login
client.exchange_code(auth_code, redirect_uri, code_verifier)

# Make API calls (auto-refreshes if needed)
token = client.get_access_token()
response = requests.get('https://api.example.com/data',
                       headers={'Authorization': f'Bearer {token}'})

# Logout
client.revoke_token(client.refresh_token, 'refresh_token')

Security Considerations

1. Token Rotation (Implemented )

  • Each refresh token is single-use only
  • After use, old refresh token is immediately revoked
  • New refresh token is issued
  • Prevents replay attacks

2. Token Family Tracking (Implemented )

  • All refresh tokens in a rotation chain share a token_family_id
  • If a revoked refresh token is reused → entire family is revoked
  • Detects stolen token attacks

3. Refresh Token Binding

  • Refresh tokens are bound to:
    • Specific client (client_id)
    • Specific user
    • Specific scopes
  • Cannot be used by different clients

4. Expiration Times (Configurable per application)

  • Access tokens: 5 minutes - 24 hours (default: 1 hour)
  • Refresh tokens: 1 day - 90 days (default: 30 days)
  • ID tokens: 5 minutes - 24 hours (default: 1 hour)

Discovery Endpoint Updates

The OIDC discovery endpoint now advertises refresh token support:

GET https://auth.example.com/.well-known/openid-configuration

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/oauth/authorize",
  "token_endpoint": "https://auth.example.com/oauth/token",
  "revocation_endpoint": "https://auth.example.com/oauth/revoke",
  "userinfo_endpoint": "https://auth.example.com/oauth/userinfo",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "scopes_supported": ["openid", "profile", "email", "groups"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
  ...
}

Testing Your Implementation

Test 1: Initial Token Exchange

# Get authorization code (manual - visit in browser)
# Then exchange for tokens:

curl -X POST https://auth.example.com/oauth/token \
  -d "grant_type=authorization_code" \
  -d "code=YOUR_AUTH_CODE" \
  -d "redirect_uri=https://yourapp.com/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "code_verifier=YOUR_CODE_VERIFIER"

# Response should include refresh_token

Test 2: Token Refresh

curl -X POST https://auth.example.com/oauth/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

# Response should include NEW access_token and NEW refresh_token

Test 3: Token Revocation

curl -X POST https://auth.example.com/oauth/revoke \
  -d "token=YOUR_REFRESH_TOKEN" \
  -d "token_type_hint=refresh_token" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

# Should return 200 OK

Test 4: Reuse Detection (Security Test)

# 1. Use refresh token to get new tokens
curl -X POST ... (as in Test 2)

# 2. Try to use the OLD refresh token again
curl -X POST ... (with OLD refresh_token)

# Should return error: "invalid_grant" - token has been revoked

FAQ

Q: How long do refresh tokens last?

A: By default, 30 days. This is configurable per application (1-90 days).

Q: Can I use the same refresh token multiple times?

A: No. Refresh tokens are single-use. After using a refresh token, you get a new one.

Q: What happens if my refresh token is stolen?

A: If someone tries to use a revoked refresh token, all tokens in that family are immediately revoked (token rotation security).

Q: Do I need to store the ID token?

A: Usually no. The ID token is for authentication (verify user identity). You typically decode it, verify it, extract claims, then discard it.

Q: Can I refresh an access token before it expires?

A: Yes! It's recommended to refresh tokens 5-10 minutes before expiration to avoid race conditions.

Q: What if my refresh token expires?

A: User must re-authenticate via the normal OAuth flow (redirect to /oauth/authorize).

Q: Can I revoke all of a user's sessions at once?

A: Yes, but you need to track all refresh tokens per user on your backend, then revoke them all.

Q: Are access tokens revocable?

A: Yes! You can revoke access tokens using the same /oauth/revoke endpoint.


Migration Guide (From Access Token Only)

Before (Access Token Only):

// User logs in
const tokens = await exchangeAuthCode(code);
localStorage.setItem('access_token', tokens.access_token);

// After 1 hour -> Token expires -> Redirect to login
if (isTokenExpired()) {
  window.location = '/oauth/authorize';
}

After (With Refresh Tokens):

// User logs in
const tokens = await exchangeAuthCode(code);
sessionStorage.setItem('access_token', tokens.access_token);
secureStorage.set('refresh_token', tokens.refresh_token); // Encrypted

// After 1 hour -> Refresh silently
if (isTokenExpired()) {
  const newTokens = await refreshAccessToken();
  sessionStorage.setItem('access_token', newTokens.access_token);
  secureStorage.set('refresh_token', newTokens.refresh_token);
}

Additional Resources


Support

For issues or questions about refresh token implementation, contact your Clinch administrator or check the application documentation.

Version: 1.0 Last Updated: November 2025