Add files to support brakeman and standardrb. Fix some SRB warnings

This commit is contained in:
Dan Milne
2026-01-01 13:18:30 +11:00
parent 9234904e47
commit c03034c49f
17 changed files with 4440 additions and 43 deletions

View File

@@ -0,0 +1,611 @@
# 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**
```http
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):**
```json
{
"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:**
```http
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:**
```json
{
"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:**
```http
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
```json
{
"error": "invalid_grant",
"error_description": "Invalid refresh token"
}
```
**Action:** Redirect user to /oauth/authorize for re-authentication
#### 2. Refresh Token Revoked (Reuse Detected!)
```json
{
"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
```json
{
"error": "invalid_client"
}
```
**Action:** Check your `client_id` and `client_secret`
---
## Implementation Examples
### Example 1: Node.js Express
```javascript
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
```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`**
```json
{
"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
```bash
# 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
```bash
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
```bash
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)
```bash
# 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):
```javascript
// 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):
```javascript
// 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
- **RFC 6749 (OAuth 2.0):** https://datatracker.ietf.org/doc/html/rfc6749
- **RFC 7009 (Token Revocation):** https://datatracker.ietf.org/doc/html/rfc7009
- **OIDC Core Spec:** https://openid.net/specs/openid-connect-core-1_0.html
- **OAuth 2.0 Security Best Practices:** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
---
## 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