Files
2026-04-17 23:26:01 +00:00

696 lines
21 KiB
Markdown

# Authentication
**Impact: CRITICAL**
Password authentication, OAuth2 integration, token management, MFA setup, and auth collection configuration.
---
## 1. Use Impersonation for Admin Operations
**Impact: MEDIUM (Safe admin access to user data without password sharing)**
Impersonation allows superusers to generate tokens for other users, enabling admin support tasks and API key functionality without sharing passwords.
**Incorrect (sharing credentials or bypassing auth):**
```javascript
// Bad: sharing user passwords for support
async function helpUser(userId, userPassword) {
await pb.collection('users').authWithPassword(userEmail, userPassword);
// Support team knows user's password!
}
// Bad: directly modifying records without proper context
async function fixUserData(userId) {
// Bypasses user's perspective and rules
await pb.collection('posts').update(postId, { fixed: true });
}
```
**Correct (using impersonation):**
```javascript
import PocketBase from 'pocketbase';
// Admin client with superuser auth (use environment variables, never hardcode)
const adminPb = new PocketBase(process.env.PB_URL);
await adminPb.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD
);
async function impersonateUser(userId) {
// Generate impersonation token (non-renewable)
const impersonatedClient = await adminPb
.collection('users')
.impersonate(userId, 3600); // 1 hour duration
// impersonatedClient has user's auth context
console.log('Acting as:', impersonatedClient.authStore.record.email);
// Operations use user's permissions
const userPosts = await impersonatedClient.collection('posts').getList();
return impersonatedClient;
}
// Use case: Admin viewing user's data
async function adminViewUserPosts(userId) {
const userClient = await impersonateUser(userId);
// See exactly what the user sees (respects API rules)
const posts = await userClient.collection('posts').getList();
return posts;
}
// Use case: API keys for server-to-server communication
async function createApiKey(serviceUserId) {
// Create a service impersonation token (use short durations, rotate regularly)
const serviceClient = await adminPb
.collection('service_accounts')
.impersonate(serviceUserId, 86400); // 24 hours max, rotate via scheduled task
// Return token for service to use
return serviceClient.authStore.token;
}
// Using API key token in another service
async function useApiKey(apiToken) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Manually set the token
pb.authStore.save(apiToken, null);
// Now requests use the service account's permissions
const data = await pb.collection('data').getList();
return data;
}
```
**Important considerations:**
```javascript
// Impersonation tokens are non-renewable
const client = await adminPb.collection('users').impersonate(userId, 3600);
// This will fail - can't refresh impersonation tokens
try {
await client.collection('users').authRefresh();
} catch (error) {
// Expected: impersonation tokens can't be refreshed
}
// For continuous access, generate new token when needed
async function getImpersonatedClient(userId) {
// Check if existing token is still valid
if (cachedClient?.authStore.isValid) {
return cachedClient;
}
// Generate fresh token
return await adminPb.collection('users').impersonate(userId, 3600);
}
```
**Security best practices:**
- Use short durations for support tasks
- Log all impersonation events
- Restrict impersonation to specific admin roles
- Never expose impersonation capability in client code
- Use dedicated service accounts for API keys
Reference: [PocketBase Impersonation](https://pocketbase.io/docs/authentication/#impersonate-authentication)
## 2. Implement Multi-Factor Authentication
**Impact: HIGH (Additional security layer for sensitive applications)**
MFA requires users to authenticate with two different methods. PocketBase supports OTP (One-Time Password) via email as the second factor.
**Incorrect (single-factor only for sensitive apps):**
```javascript
// Insufficient for sensitive applications
async function login(email, password) {
const authData = await pb.collection('users').authWithPassword(email, password);
// User immediately has full access - no second factor
return authData;
}
```
**Correct (MFA flow with OTP):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function loginWithMFA(email, password) {
try {
// First factor: password
const authData = await pb.collection('users').authWithPassword(email, password);
// If MFA not required, auth succeeds immediately
return { success: true, authData };
} catch (error) {
// MFA required - returns 401 with mfaId
if (error.status === 401 && error.response?.mfaId) {
return {
success: false,
mfaRequired: true,
mfaId: error.response.mfaId
};
}
throw error;
}
}
async function requestOTP(email) {
// Request OTP to be sent via email
const result = await pb.collection('users').requestOTP(email);
// Returns otpId - needed to verify the OTP
// Note: Returns otpId even if email doesn't exist (prevents enumeration)
return result.otpId;
}
async function completeMFAWithOTP(mfaId, otpId, otpCode) {
try {
// Second factor: OTP verification
const authData = await pb.collection('users').authWithOTP(
otpId,
otpCode,
{ mfaId } // Include mfaId from first factor
);
return { success: true, authData };
} catch (error) {
if (error.status === 400) {
throw new Error('Invalid or expired code');
}
throw error;
}
}
// Complete flow example
async function fullMFAFlow(email, password, otpCode = null) {
// Step 1: Password authentication
const step1 = await loginWithMFA(email, password);
if (step1.success) {
return step1.authData; // MFA not required
}
if (step1.mfaRequired) {
// Step 2: Request OTP
const otpId = await requestOTP(email);
// Step 3: UI prompts user for OTP code...
// (In real app, wait for user input)
if (otpCode) {
// Step 4: Complete MFA
const step2 = await completeMFAWithOTP(step1.mfaId, otpId, otpCode);
return step2.authData;
}
return { pendingMFA: true, mfaId: step1.mfaId, otpId };
}
}
```
**Configure MFA (Admin UI or API):**
```javascript
// Enable MFA on auth collection (superuser only)
await pb.collections.update('users', {
mfa: {
enabled: true,
duration: 1800, // MFA session duration (30 min)
rule: '' // When to require MFA (empty = always for all users)
// rule: '@request.auth.role = "admin"' // Only for admins
},
otp: {
enabled: true,
duration: 300, // OTP validity (5 min)
length: 6, // OTP code length
emailTemplate: {
subject: 'Your verification code',
body: 'Your code is: {OTP}'
}
}
});
```
**MFA best practices:**
- Always enable for admin accounts
- Consider making MFA optional for regular users
- Use short OTP durations (5-10 minutes)
- Implement rate limiting on OTP requests
- Log MFA events for security auditing
Reference: [PocketBase MFA](https://pocketbase.io/docs/authentication/#mfa)
## 3. Integrate OAuth2 Providers Correctly
**Impact: CRITICAL (Secure third-party authentication with proper flow handling)**
OAuth2 integration should use the all-in-one method for simplicity and security. Manual code exchange should only be used when necessary (e.g., mobile apps with deep links).
**Incorrect (manual implementation without SDK):**
```javascript
// Don't manually handle OAuth flow
async function loginWithGoogle() {
// Redirect user to Google manually
window.location.href = 'https://accounts.google.com/oauth/authorize?...';
}
// Manual callback handling
async function handleCallback(code) {
// Exchange code manually - error prone!
const response = await fetch('/api/auth/callback', {
method: 'POST',
body: JSON.stringify({ code })
});
}
```
**Correct (using SDK's all-in-one method):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// All-in-one OAuth2 (recommended for web apps)
async function loginWithOAuth2(providerName) {
try {
const authData = await pb.collection('users').authWithOAuth2({
provider: providerName, // 'google', 'github', 'microsoft', etc.
// Optional: create new user data if not exists
createData: {
emailVisibility: true,
name: '' // Will be populated from OAuth provider
}
});
console.log('Logged in via', providerName);
console.log('User:', authData.record.email);
console.log('Is new user:', authData.meta?.isNew);
return authData;
} catch (error) {
if (error.isAbort) {
console.log('OAuth popup was closed');
return null;
}
throw error;
}
}
// Usage
document.getElementById('google-btn').onclick = () => loginWithOAuth2('google');
document.getElementById('github-btn').onclick = () => loginWithOAuth2('github');
```
**Manual code exchange (for React Native / deep links):**
```javascript
// Only use when all-in-one isn't possible
async function loginWithOAuth2Manual() {
// Get auth methods - PocketBase provides state and codeVerifier
const authMethods = await pb.collection('users').listAuthMethods();
const provider = authMethods.oauth2.providers.find(p => p.name === 'google');
// Store the provider's state and codeVerifier for callback verification
// PocketBase generates these for you - don't create your own
sessionStorage.setItem('oauth_state', provider.state);
sessionStorage.setItem('oauth_code_verifier', provider.codeVerifier);
// Build the OAuth URL using provider.authURL + redirect
const redirectUrl = window.location.origin + '/oauth-callback';
const authUrl = provider.authURL + encodeURIComponent(redirectUrl);
// Redirect to OAuth provider
window.location.href = authUrl;
}
// In your callback handler (e.g., /oauth-callback page):
async function handleOAuth2Callback() {
const params = new URLSearchParams(window.location.search);
// CSRF protection: verify state matches
if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
throw new Error('State mismatch - potential CSRF attack');
}
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
const redirectUrl = window.location.origin + '/oauth-callback';
// Exchange code for auth token
const authData = await pb.collection('users').authWithOAuth2Code(
'google',
code,
codeVerifier,
redirectUrl,
{ emailVisibility: true }
);
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
return authData;
}
```
**Configure OAuth2 provider (Admin UI or API):**
```javascript
// Via API (superuser only) - usually done in Admin UI
// IMPORTANT: Never hardcode client secrets. Use environment variables.
await pb.collections.update('users', {
oauth2: {
enabled: true,
providers: [{
name: 'google',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
}],
mappedFields: {
avatarURL: 'avatar' // Map OAuth field to collection field
}
}
});
```
Reference: [PocketBase OAuth2](https://pocketbase.io/docs/authentication/#oauth2-authentication)
## 4. Use authWithOTP for Email One-Time Codes, Rate-Limit requestOTP
**Impact: HIGH (OTP endpoints are unauthenticated; unthrottled requestOTP enables email bombing and enumeration)**
Auth collections can enable **OTP login** from the admin UI (Collection → Options → "Enable OTP"). The client flow is two steps: `requestOTP(email)` returns an `otpId`, then `authWithOTP(otpId, code)` exchanges the id + code for an auth token. Two things trip people up: (1) the OTP response is **the same whether the email exists or not** - do not break that by leaking a distinct error; (2) `requestOTP` sends an email, so **it must be rate-limited** or an attacker can use it to spam any address.
**Incorrect (leaks existence, custom requestOTP with no rate limit):**
```javascript
// ❌ Client-side existence check - ignore the 404 and expose it to the user
try {
await pb.collection("users").getFirstListItem(`email="${email}"`);
} catch (e) {
alert("No account with that email"); // ❌ account enumeration
return;
}
// ❌ Ad-hoc route with no rate limit - attacker hammers this to spam mailboxes
routerAdd("POST", "/api/myapp/otp", (e) => {
const body = e.requestInfo().body;
const user = $app.findAuthRecordByEmail("users", body.email);
// send custom email...
return e.json(200, { ok: true });
});
```
**Correct (use the built-in flow, step 1 always returns an otpId):**
```javascript
// Step 1: request the code. Always returns { otpId } - even if the email
// does not exist, PocketBase returns a synthetic id so enumeration is
// impossible. Treat every response as success from the UI perspective.
const { otpId } = await pb.collection("users").requestOTP("user@example.com");
// Step 2: exchange otpId + the 8-digit code the user typed
const authData = await pb.collection("users").authWithOTP(
otpId,
"12345678",
);
// pb.authStore is now populated
```
```go
// Go side - rate-limit and log if you wrap your own endpoint
app.OnRecordRequestOTPRequest("users").BindFunc(func(e *core.RecordRequestOTPRequestEvent) error {
// e.Collection, e.Record (may be nil - synthetic id path),
// e.Email (always present), e.Password (unused for OTP)
e.App.Logger().Info("otp requested",
"email", e.Email,
"ip", e.RequestInfo.Headers["x_forwarded_for"])
return e.Next() // REQUIRED
})
```
**Rules:**
- `requestOTP` **always returns 200 with an otpId**, even for non-existent emails - preserve that by never adding a pre-check or a different error path.
- Enable the built-in rate limiter (see `deploy-rate-limiting.md`) and raise the cost for the `*:requestOTP` label. Without this, an attacker can email-bomb arbitrary users.
- The OTP code is 8 digits by default, with a 3-minute TTL. Do not extend the TTL - short windows are the whole point.
- `authWithOTP` consumes the code; a successful call invalidates the `otpId`. Always show a generic "invalid or expired code" on failure.
- If you want OTP **without a password**, set the collection's `Password` option to off and `OTP` on. If both are enabled, users can use either.
- OTP emails are sent via the configured SMTP server. In dev, point SMTP at Mailpit or a console logger before testing - do **not** ship with the default "no-reply@example.com" sender.
Reference: [Auth with OTP](https://pocketbase.io/docs/authentication/#auth-with-otp) · [JS SDK - authWithOTP](https://github.com/pocketbase/js-sdk#authwithotp)
## 5. Implement Secure Password Authentication
**Impact: CRITICAL (Secure user login with proper error handling and token management)**
Password authentication should include proper error handling, avoid exposing whether emails exist, and correctly manage the auth store.
**Incorrect (exposing information and poor error handling):**
```javascript
// Dangerous: exposes whether email exists
async function login(email, password) {
const user = await pb.collection('users').getFirstListItem(`email = "${email}"`);
if (!user) {
throw new Error('Email not found'); // Reveals email doesn't exist
}
// Manual password check - never do this!
if (user.password !== password) {
throw new Error('Wrong password'); // Reveals password is wrong
}
return user;
}
```
**Correct (secure authentication):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
async function login(email, password) {
try {
// authWithPassword handles hashing and returns token
const authData = await pb.collection('users').authWithPassword(email, password);
// Token is automatically stored in pb.authStore
console.log('Logged in as:', authData.record.email);
console.log('Token valid:', pb.authStore.isValid);
return authData;
} catch (error) {
// Generic error message - don't reveal if email exists
if (error.status === 400) {
throw new Error('Invalid email or password');
}
throw error;
}
}
// Check if user is authenticated
function isAuthenticated() {
return pb.authStore.isValid;
}
// Get current user
function getCurrentUser() {
return pb.authStore.record;
}
// Logout
function logout() {
pb.authStore.clear();
}
// Listen for auth changes
pb.authStore.onChange((token, record) => {
console.log('Auth state changed:', record?.email || 'logged out');
}, true); // true = fire immediately with current state
```
**Auth collection configuration for password auth:**
```javascript
// When creating auth collection via API (superuser only)
await pb.collections.create({
name: 'users',
type: 'auth',
fields: [
{ name: 'name', type: 'text' },
{ name: 'avatar', type: 'file', options: { maxSelect: 1 } }
],
passwordAuth: {
enabled: true,
identityFields: ['email', 'username'] // Fields that can be used to login
},
// Require minimum password length
// (configured in Admin UI under collection options)
});
```
**Security considerations:**
- Never store passwords in plain text
- Use generic error messages
- Implement rate limiting on your server
- Consider adding MFA for sensitive applications
Reference: [PocketBase Auth](https://pocketbase.io/docs/authentication/)
## 6. Manage Auth Tokens Properly
**Impact: CRITICAL (Prevents unauthorized access, handles token expiration gracefully)**
Auth tokens should be refreshed before expiration, validated on critical operations, and properly cleared on logout. The SDK's authStore handles most of this automatically.
**Incorrect (ignoring token expiration):**
```javascript
// Bad: never checking token validity
async function fetchUserData() {
// Token might be expired!
const records = await pb.collection('posts').getList();
return records;
}
// Bad: manually managing tokens
let authToken = localStorage.getItem('token');
fetch('/api/posts', {
headers: { 'Authorization': authToken } // Token might be invalid
});
```
**Correct (proper token management):**
```javascript
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://127.0.0.1:8090');
// Check token validity before operations
async function fetchSecureData() {
// authStore.isValid is a client-side check only (JWT expiry parsing).
// Always verify server-side with authRefresh() for critical operations.
if (!pb.authStore.isValid) {
throw new Error('Please log in');
}
return pb.collection('posts').getList();
}
// Refresh token periodically or before expiration
async function refreshAuthIfNeeded() {
if (!pb.authStore.isValid) {
return false;
}
try {
// Verifies current token and returns fresh one
await pb.collection('users').authRefresh();
console.log('Token refreshed');
return true;
} catch (error) {
// Token invalid - user needs to re-authenticate
pb.authStore.clear();
return false;
}
}
// Auto-refresh on app initialization
async function initializeAuth() {
if (pb.authStore.token) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}
// Listen for auth changes and handle expiration
pb.authStore.onChange((token, record) => {
if (!token) {
// User logged out or token cleared
redirectToLogin();
}
});
// Setup periodic refresh (e.g., every 10 minutes)
setInterval(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
}, 10 * 60 * 1000);
```
**SSR / Server-side token handling:**
```javascript
// Server-side: create fresh client per request
export async function handleRequest(request) {
const pb = new PocketBase('http://127.0.0.1:8090');
// Load auth from cookie
pb.authStore.loadFromCookie(request.headers.get('cookie') || '');
// Validate and refresh
if (pb.authStore.isValid) {
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
}
}
// ... handle request ...
// Send updated cookie with secure options
const response = new Response();
response.headers.set('set-cookie', pb.authStore.exportToCookie({
httpOnly: true, // Prevent XSS access to auth token
secure: true, // HTTPS only
sameSite: 'Lax', // CSRF protection
}));
return response;
}
```
**Token configuration (Admin UI or migration):**
```javascript
// Configure token durations (superuser only)
await pb.collections.update('users', {
authToken: {
duration: 1209600 // 14 days in seconds
},
verificationToken: {
duration: 604800 // 7 days
}
});
```
Reference: [PocketBase Auth Store](https://pocketbase.io/docs/authentication/)