diff --git a/.env.example b/.env.example index 8f43c61..0f7525f 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,24 @@ # ATAuth Gateway Configuration # Copy this file to .env and customize for your deployment +# ============================================================================= +# SECURITY NOTICE +# ============================================================================= +# 1. NEVER commit your .env file to version control +# 2. Generate all tokens with: openssl rand -hex 32 +# 3. Use HTTPS in production (configure via reverse proxy) +# 4. Restrict CORS_ORIGINS to your actual domains +# ============================================================================= + # ============================================================================= # REQUIRED SETTINGS # ============================================================================= # Admin API token for registering applications -# Generate with: openssl rand -hex 32 -ADMIN_TOKEN=your-secure-admin-token-here +# SECURITY: Generate a unique 64-character hex token: +# openssl rand -hex 32 +# DO NOT use the placeholder value in production! +ADMIN_TOKEN= # Your gateway's public URL (where users will be redirected) # This must be accessible from the internet for OAuth to work @@ -19,8 +30,10 @@ OAUTH_REDIRECT_URI=https://auth.yourdomain.com/auth/callback # ============================================================================= # Allowed CORS origins (comma-separated) -# Add your app domains here -CORS_ORIGINS=https://app1.yourdomain.com,https://app2.yourdomain.com +# SECURITY: List ONLY your actual application domains +# DO NOT use wildcards (*) in production +# Example: CORS_ORIGINS=https://myapp.com,https://admin.myapp.com +CORS_ORIGINS= # ============================================================================= # SELF-HOSTED PDS CONFIGURATION diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml deleted file mode 100644 index 6737fe4..0000000 --- a/.gitea/workflows/docker.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Build and Push Docker Image - -on: - push: - branches: [main] - tags: ['v*'] - pull_request: - branches: [main] - -env: - REGISTRY: your-gitea-instance.example.com - IMAGE_NAME: arcnode.xyz/atauth-gateway - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Gitea Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ gitea.actor }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: ./gateway - file: ./gateway/Dockerfile - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64 -# Trigger rebuild diff --git a/docker-compose.yml b/docker-compose.yml index 6794e38..706a5f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,9 @@ services: container_name: atauth-gateway restart: unless-stopped ports: - - "3100:3100" + # Bind to localhost only by default for security + # Use a reverse proxy (Traefik/Caddy) for external access + - "127.0.0.1:3100:3100" environment: # Gateway configuration - PORT=3100 diff --git a/gateway/src/routes/auth.ts b/gateway/src/routes/auth.ts index 67ee1ad..ae65297 100644 --- a/gateway/src/routes/auth.ts +++ b/gateway/src/routes/auth.ts @@ -11,6 +11,54 @@ import { DatabaseService } from '../services/database.js'; import { createGatewayToken } from '../utils/hmac.js'; import { internalError } from '../utils/errors.js'; +/** + * Validate a redirect URI against an app's allowed callback URL. + * + * SECURITY: Prevents open redirect attacks by ensuring the redirect_uri + * matches the app's registered callback_url origin (or starts with it). + * + * @param redirectUri - The redirect URI from the request + * @param allowedUrl - The app's registered callback_url + * @returns true if valid, false otherwise + */ +function isValidRedirectUri(redirectUri: string, allowedUrl: string | undefined): boolean { + if (!redirectUri) return false; + + try { + const redirect = new URL(redirectUri); + const protocol = redirect.protocol; + + // Only allow HTTPS in production (or HTTP for localhost dev) + const isLocalhost = redirect.hostname === 'localhost' || redirect.hostname === '127.0.0.1'; + if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) { + return false; + } + + // If app has no registered callback, reject external redirects + if (!allowedUrl) { + return false; + } + + const allowed = new URL(allowedUrl); + + // Must match origin (scheme + host + port) + if (redirect.origin !== allowed.origin) { + return false; + } + + // Redirect path must start with allowed path (for path restrictions) + // This allows callback_url="https://app.com/auth" to accept + // redirects to "https://app.com/auth/callback" but not "https://app.com/admin" + if (allowed.pathname !== '/' && !redirect.pathname.startsWith(allowed.pathname)) { + return false; + } + + return true; + } catch { + return false; + } +} + export function createAuthRoutes( db: DatabaseService, oauth: OAuthService @@ -52,10 +100,25 @@ export function createAuthRoutes( }); } + // Validate redirect_uri if provided + let validatedRedirectUri: string | undefined; + if (typeof redirect_uri === 'string') { + if (!isValidRedirectUri(redirect_uri, app.callback_url)) { + return res.status(400).json({ + error: 'invalid_redirect_uri', + message: 'redirect_uri does not match the registered callback URL for this application', + }); + } + validatedRedirectUri = redirect_uri; + } else if (app.callback_url) { + // Use the registered callback URL as default + validatedRedirectUri = app.callback_url; + } + const { url, state } = await oauth.generateAuthUrl( app_id, handle, - typeof redirect_uri === 'string' ? redirect_uri : undefined + validatedRedirectUri ); res.json({ @@ -131,6 +194,15 @@ export function createAuthRoutes( }); if (savedState.redirect_uri) { + // Defense in depth: re-validate redirect URI before redirecting + if (!isValidRedirectUri(savedState.redirect_uri, app.callback_url)) { + console.error(`[SECURITY] Invalid redirect_uri in saved state: ${savedState.redirect_uri}`); + return res.status(400).json({ + error: 'invalid_redirect_uri', + message: 'Redirect URI validation failed', + }); + } + const redirectUrl = new URL(savedState.redirect_uri); // Use URL fragment (hash) for sensitive data to prevent logging in: // - Server access logs diff --git a/src/lib.rs b/src/lib.rs index e45ac91..3ed8044 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,6 +77,9 @@ pub const MAX_DID_LENGTH: usize = 512; /// Maximum handle length pub const MAX_HANDLE_LENGTH: usize = 256; +/// Minimum secret key length (32 bytes = 256 bits for HMAC-SHA256 security) +pub const MIN_SECRET_LENGTH: usize = 32; + /// Prelude module for common imports pub mod prelude { pub use crate::error::{AuthError, AuthResult}; diff --git a/src/token.rs b/src/token.rs index b5831d7..b9119c0 100644 --- a/src/token.rs +++ b/src/token.rs @@ -11,7 +11,7 @@ use subtle::ConstantTimeEq; use crate::error::{AuthError, AuthResult}; use crate::validation::{validate_did, validate_handle}; -use crate::MAX_TOKEN_LENGTH; +use crate::{MAX_TOKEN_LENGTH, MIN_SECRET_LENGTH}; type HmacSha256 = Hmac; @@ -97,9 +97,48 @@ pub struct TokenVerifier { impl TokenVerifier { /// Create a new token verifier with the given HMAC secret. /// - /// The secret should be a cryptographically random value shared - /// between your application and the auth gateway. - pub fn new(secret: &[u8]) -> Self { + /// The secret must be at least 32 bytes (256 bits) for security. + /// Use a cryptographically random value shared between your + /// application and the auth gateway. + /// + /// # Errors + /// + /// Returns an error if the secret is shorter than 32 bytes. + /// + /// # Example + /// + /// ```rust + /// use atauth::TokenVerifier; + /// + /// // Good: 32+ byte secret + /// let verifier = TokenVerifier::new(b"a]j2k#9xLmN!pQ4rS7tU0vW3yZ6bC8dE").unwrap(); + /// + /// // Bad: Too short (will error) + /// assert!(TokenVerifier::new(b"short").is_err()); + /// ``` + pub fn new(secret: &[u8]) -> AuthResult { + if secret.len() < MIN_SECRET_LENGTH { + return Err(AuthError::InvalidFormat(format!( + "Secret key must be at least {} bytes ({} bits) for security. Got {} bytes.", + MIN_SECRET_LENGTH, + MIN_SECRET_LENGTH * 8, + secret.len() + ))); + } + Ok(Self { + secret: secret.to_vec(), + validate_formats: true, + clock_skew_seconds: 30, + }) + } + + /// Create a token verifier without checking secret length. + /// + /// **Warning**: This bypasses the minimum key length check. Only use this + /// for testing or when you have validated the key length yourself. + /// + /// For production code, prefer `new()` which enforces security requirements. + pub fn new_unchecked(secret: &[u8]) -> Self { Self { secret: secret.to_vec(), validate_formats: true, @@ -108,18 +147,22 @@ impl TokenVerifier { } /// Create a verifier from a hex-encoded secret string. + /// + /// The decoded secret must be at least 32 bytes. pub fn from_hex(hex_secret: &str) -> AuthResult { let secret = hex::decode(hex_secret) .map_err(|e| AuthError::InvalidFormat(format!("Invalid hex secret: {}", e)))?; - Ok(Self::new(&secret)) + Self::new(&secret) } /// Create a verifier from a base64-encoded secret string. + /// + /// The decoded secret must be at least 32 bytes. pub fn from_base64(b64_secret: &str) -> AuthResult { let secret = URL_SAFE_NO_PAD .decode(b64_secret) .map_err(|e| AuthError::InvalidFormat(format!("Invalid base64 secret: {}", e)))?; - Ok(Self::new(&secret)) + Self::new(&secret) } /// Set whether to validate DID and handle formats. @@ -297,10 +340,13 @@ mod tests { } } + // Test secret that meets the 32-byte minimum requirement + const TEST_SECRET: &[u8; 32] = b"test-secret-key-32bytes-long!!!"; + const TEST_SECRET_2: &[u8; 32] = b"another-secret-32-bytes-long!!!"; + #[test] fn test_sign_and_verify() { - let secret = b"test-secret-key-12345"; - let verifier = TokenVerifier::new(secret); + let verifier = TokenVerifier::new(TEST_SECRET).unwrap(); let payload = create_test_payload(); let token = verifier.sign(&payload).unwrap(); @@ -313,8 +359,8 @@ mod tests { #[test] fn test_invalid_signature() { - let verifier = TokenVerifier::new(b"secret1"); - let other_verifier = TokenVerifier::new(b"secret2"); + let verifier = TokenVerifier::new(TEST_SECRET).unwrap(); + let other_verifier = TokenVerifier::new(TEST_SECRET_2).unwrap(); let payload = create_test_payload(); let token = verifier.sign(&payload).unwrap(); @@ -326,9 +372,20 @@ mod tests { )); } + #[test] + fn test_secret_too_short() { + // Secrets shorter than 32 bytes should be rejected + assert!(TokenVerifier::new(b"short").is_err()); + assert!(TokenVerifier::new(b"").is_err()); + assert!(TokenVerifier::new(b"31-bytes-secret-not-long-enuff").is_err()); + + // Exactly 32 bytes should work + assert!(TokenVerifier::new(b"exactly-32-bytes-secret-here!!!").is_ok()); + } + #[test] fn test_expired_token() { - let verifier = TokenVerifier::new(b"secret"); + let verifier = TokenVerifier::new(TEST_SECRET).unwrap(); let now = chrono::Utc::now().timestamp(); let payload = TokenPayload { @@ -348,7 +405,7 @@ mod tests { #[test] fn test_clock_skew_tolerance() { - let verifier = TokenVerifier::new(b"secret").with_clock_skew(60); + let verifier = TokenVerifier::new(TEST_SECRET).unwrap().with_clock_skew(60); let now = chrono::Utc::now().timestamp(); // Token expired 30 seconds ago (within 60s tolerance) @@ -369,7 +426,7 @@ mod tests { #[test] fn test_invalid_format() { - let verifier = TokenVerifier::new(b"secret"); + let verifier = TokenVerifier::new(TEST_SECRET).unwrap(); // No separator assert!(matches!( @@ -392,7 +449,7 @@ mod tests { #[test] fn test_token_too_long() { - let verifier = TokenVerifier::new(b"secret"); + let verifier = TokenVerifier::new(TEST_SECRET).unwrap(); let long_token = "a".repeat(MAX_TOKEN_LENGTH + 1); assert!(matches!( diff --git a/ts/src/index.ts b/ts/src/index.ts index 64df6b7..7c18a67 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -71,6 +71,9 @@ export { export { parseOAuthState, isValidAppId, + requireHttpsInProduction, + validateGatewayUrl, + validateCallbackUrl, } from './validation'; /** diff --git a/ts/src/oauth.ts b/ts/src/oauth.ts index 1401a36..8d8e73f 100644 --- a/ts/src/oauth.ts +++ b/ts/src/oauth.ts @@ -5,14 +5,20 @@ import type { AtAuthConfig, OAuthState, OAuthCallbackResult } from './types'; import { decodeToken } from './token'; import { storeToken, storeOAuthState, getOAuthState, generateNonce } from './storage'; -import { parseOAuthState } from './validation'; +import { parseOAuthState, validateGatewayUrl, validateCallbackUrl } from './validation'; /** * Default configuration + * + * SECURITY: persistSession defaults to false (sessionStorage) for better security. + * Tokens stored in sessionStorage are cleared when the browser closes and are not + * shared across tabs, reducing XSS attack persistence. + * + * Set persistSession: true only for explicit "remember me" functionality. */ const DEFAULT_CONFIG: Partial = { storageKey: 'atauth_token', - persistSession: true, + persistSession: false, refreshThreshold: 300, }; @@ -29,6 +35,12 @@ export function buildAuthUrl( ): string { const { gatewayUrl, appId, callbackUrl } = config; + // Validate URLs require HTTPS in production + validateGatewayUrl(gatewayUrl); + if (callbackUrl) { + validateCallbackUrl(callbackUrl); + } + // Generate CSRF nonce const nonce = generateNonce(); diff --git a/ts/src/storage.ts b/ts/src/storage.ts index 498b828..4ae3c48 100644 --- a/ts/src/storage.ts +++ b/ts/src/storage.ts @@ -1,11 +1,21 @@ /** * Token storage utilities + * + * SECURITY: By default, tokens are stored in sessionStorage which: + * - Clears when the browser/tab is closed + * - Is not shared across tabs + * - Provides better protection against XSS persistence + * + * Set persist=true only for explicit "remember me" functionality. */ const DEFAULT_STORAGE_KEY = 'atauth_token'; /** * Get the appropriate storage mechanism. + * + * @param persist - Use localStorage (true) for persistent sessions, + * sessionStorage (false, default) for session-only storage */ function getStorage(persist: boolean): Storage | null { if (typeof window === 'undefined') { @@ -19,12 +29,12 @@ function getStorage(persist: boolean): Storage | null { * * @param token - Token to store * @param key - Storage key (default: "atauth_token") - * @param persist - Use localStorage (true) or sessionStorage (false) + * @param persist - Use localStorage (true) or sessionStorage (false, default) */ export function storeToken( token: string, key = DEFAULT_STORAGE_KEY, - persist = true + persist = false ): void { const storage = getStorage(persist); if (storage) { @@ -36,12 +46,12 @@ export function storeToken( * Retrieve stored authentication token. * * @param key - Storage key (default: "atauth_token") - * @param persist - Check localStorage (true) or sessionStorage (false) + * @param persist - Check localStorage (true) or sessionStorage (false, default) * @returns Stored token or null */ export function getStoredToken( key = DEFAULT_STORAGE_KEY, - persist = true + persist = false ): string | null { const storage = getStorage(persist); if (storage) { @@ -54,11 +64,11 @@ export function getStoredToken( * Remove stored authentication token. * * @param key - Storage key (default: "atauth_token") - * @param persist - Remove from localStorage (true) or sessionStorage (false) + * @param persist - Remove from localStorage (true) or sessionStorage (false, default) */ export function removeStoredToken( key = DEFAULT_STORAGE_KEY, - persist = true + persist = false ): void { const storage = getStorage(persist); if (storage) { @@ -70,12 +80,12 @@ export function removeStoredToken( * Check if a token is stored. * * @param key - Storage key (default: "atauth_token") - * @param persist - Check localStorage (true) or sessionStorage (false) + * @param persist - Check localStorage (true) or sessionStorage (false, default) * @returns True if token exists */ export function hasStoredToken( key = DEFAULT_STORAGE_KEY, - persist = true + persist = false ): boolean { return getStoredToken(key, persist) !== null; } diff --git a/ts/src/validation.ts b/ts/src/validation.ts index a833614..844b8de 100644 --- a/ts/src/validation.ts +++ b/ts/src/validation.ts @@ -4,6 +4,73 @@ import type { OAuthState } from './types'; +/** + * Check if the current environment is production. + * Uses common environment indicators. + */ +function isProduction(): boolean { + if (typeof process !== 'undefined' && process.env) { + return process.env.NODE_ENV === 'production'; + } + // Browser fallback: check if running on non-localhost + if (typeof window !== 'undefined') { + const hostname = window.location?.hostname; + return hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1'; + } + return false; +} + +/** + * Require HTTPS for gateway URLs in production. + * + * In production environments, all gateway URLs must use HTTPS to protect + * tokens in transit. This prevents man-in-the-middle attacks. + * + * @param url - The URL to validate + * @param context - Description for error message (e.g., "gatewayUrl") + * @throws Error if HTTPS is required but not used + */ +export function requireHttpsInProduction(url: string, context = 'URL'): void { + if (!isProduction()) { + return; // Allow HTTP in development + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + throw new Error( + `Security error: ${context} must use HTTPS in production. ` + + `Got "${parsed.protocol}" in "${url}"` + ); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith('Security error:')) { + throw e; + } + throw new Error(`Invalid ${context}: ${url}`); + } +} + +/** + * Validate gateway URL for security requirements. + * + * @param gatewayUrl - The gateway URL to validate + * @throws Error if URL is invalid or insecure + */ +export function validateGatewayUrl(gatewayUrl: string): void { + requireHttpsInProduction(gatewayUrl, 'gatewayUrl'); +} + +/** + * Validate callback URL for security requirements. + * + * @param callbackUrl - The callback URL to validate + * @throws Error if URL is invalid or insecure + */ +export function validateCallbackUrl(callbackUrl: string): void { + requireHttpsInProduction(callbackUrl, 'callbackUrl'); +} + /** * Maximum allowed size for OAuth state string to prevent DoS. */