Add files to support brakeman and standardrb. Fix some SRB warnings
This commit is contained in:
611
docs/oidc-refresh-tokens-client-guide.md
Normal file
611
docs/oidc-refresh-tokens-client-guide.md
Normal 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
|
||||
Reference in New Issue
Block a user