diff --git a/pkg/security/PASSKEY_QUICK_REFERENCE.md b/pkg/security/PASSKEY_QUICK_REFERENCE.md new file mode 100644 index 0000000..74f41fa --- /dev/null +++ b/pkg/security/PASSKEY_QUICK_REFERENCE.md @@ -0,0 +1,208 @@ +# Passkey Authentication Quick Reference + +## Overview +Passkey authentication (WebAuthn/FIDO2) is now integrated into the DatabaseAuthenticator. This provides passwordless authentication using biometrics, security keys, or device credentials. + +## Setup + +### Database Schema +Run the passkey SQL schema (in database_schema.sql): +- Creates `user_passkey_credentials` table +- Adds stored procedures for passkey operations + +### Go Code +```go +// Create passkey provider +passkeyProvider := security.NewDatabasePasskeyProvider(db, + security.DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + RPOrigin: "https://example.com", + Timeout: 60000, + }) + +// Create authenticator with passkey support +auth := security.NewDatabaseAuthenticatorWithOptions(db, + security.DatabaseAuthenticatorOptions{ + PasskeyProvider: passkeyProvider, + }) + +// Or add passkey to existing authenticator +auth = security.NewDatabaseAuthenticator(db).WithPasskey(passkeyProvider) +``` + +## Registration Flow + +### Backend - Step 1: Begin Registration +```go +options, err := auth.BeginPasskeyRegistration(ctx, + security.PasskeyBeginRegistrationRequest{ + UserID: 1, + Username: "alice", + DisplayName: "Alice Smith", + }) +// Send options to client as JSON +``` + +### Frontend - Step 2: Create Credential +```javascript +// Convert options from server +options.challenge = base64ToArrayBuffer(options.challenge); +options.user.id = base64ToArrayBuffer(options.user.id); + +// Create credential +const credential = await navigator.credentials.create({ + publicKey: options +}); + +// Send credential back to server +``` + +### Backend - Step 3: Complete Registration +```go +credential, err := auth.CompletePasskeyRegistration(ctx, + security.PasskeyRegisterRequest{ + UserID: 1, + Response: clientResponse, + ExpectedChallenge: storedChallenge, + CredentialName: "My iPhone", + }) +``` + +## Authentication Flow + +### Backend - Step 1: Begin Authentication +```go +options, err := auth.BeginPasskeyAuthentication(ctx, + security.PasskeyBeginAuthenticationRequest{ + Username: "alice", // Optional for resident key + }) +// Send options to client as JSON +``` + +### Frontend - Step 2: Get Credential +```javascript +// Convert options from server +options.challenge = base64ToArrayBuffer(options.challenge); + +// Get credential +const credential = await navigator.credentials.get({ + publicKey: options +}); + +// Send assertion back to server +``` + +### Backend - Step 3: Complete Authentication +```go +loginResponse, err := auth.LoginWithPasskey(ctx, + security.PasskeyLoginRequest{ + Response: clientAssertion, + ExpectedChallenge: storedChallenge, + Claims: map[string]any{ + "ip_address": "192.168.1.1", + "user_agent": "Mozilla/5.0...", + }, + }) +// Returns session token and user info +``` + +## Credential Management + +### List Credentials +```go +credentials, err := auth.GetPasskeyCredentials(ctx, userID) +``` + +### Update Credential Name +```go +err := auth.UpdatePasskeyCredentialName(ctx, userID, credentialID, "New Name") +``` + +### Delete Credential +```go +err := auth.DeletePasskeyCredential(ctx, userID, credentialID) +``` + +## HTTP Endpoints Example + +### POST /api/passkey/register/begin +Request: `{user_id, username, display_name}` +Response: PasskeyRegistrationOptions + +### POST /api/passkey/register/complete +Request: `{user_id, response, credential_name}` +Response: PasskeyCredential + +### POST /api/passkey/login/begin +Request: `{username}` (optional) +Response: PasskeyAuthenticationOptions + +### POST /api/passkey/login/complete +Request: `{response}` +Response: LoginResponse with session token + +### GET /api/passkey/credentials +Response: Array of PasskeyCredential + +### DELETE /api/passkey/credentials/{id} +Request: `{credential_id}` +Response: 204 No Content + +## Database Stored Procedures + +- `resolvespec_passkey_store_credential` - Store new credential +- `resolvespec_passkey_get_credential` - Get credential by ID +- `resolvespec_passkey_get_user_credentials` - Get all user credentials +- `resolvespec_passkey_update_counter` - Update sign counter (clone detection) +- `resolvespec_passkey_delete_credential` - Delete credential +- `resolvespec_passkey_update_name` - Update credential name +- `resolvespec_passkey_get_credentials_by_username` - Get credentials for login + +## Security Features + +- **Clone Detection**: Sign counter validation detects credential cloning +- **Attestation Support**: Stores attestation type (none, indirect, direct) +- **Transport Options**: Tracks authenticator transports (usb, nfc, ble, internal) +- **Backup State**: Tracks if credential is backed up/synced +- **User Verification**: Supports preferred/required user verification + +## Important Notes + +1. **WebAuthn Library**: Current implementation is simplified. For production, use a proper WebAuthn library like `github.com/go-webauthn/webauthn` for full verification. + +2. **Challenge Storage**: Store challenges securely in session/cache. Never expose challenges to client beyond initial request. + +3. **HTTPS Required**: Passkeys only work over HTTPS (except localhost). + +4. **Browser Support**: Check browser compatibility for WebAuthn API. + +5. **Relying Party ID**: Must match your domain exactly. + +## Client-Side Helper Functions + +```javascript +function base64ToArrayBuffer(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} +``` + +## Testing + +Run tests: `go test -v ./pkg/security -run Passkey` + +All passkey functionality includes comprehensive tests using sqlmock. diff --git a/pkg/security/database_schema.sql b/pkg/security/database_schema.sql index 0f7eca9..12f31f1 100644 --- a/pkg/security/database_schema.sql +++ b/pkg/security/database_schema.sql @@ -1075,3 +1075,325 @@ $$ LANGUAGE plpgsql; -- Validate backup code -- SELECT * FROM resolvespec_totp_validate_backup_code(1, 'abc123'); + +-- ============================================ +-- Passkey/WebAuthn Credentials Table +-- ============================================ + +-- Passkey credentials table for WebAuthn/FIDO2 authentication +CREATE TABLE IF NOT EXISTS user_passkey_credentials ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, -- Raw credential ID from authenticator + public_key BYTEA NOT NULL, -- COSE public key + attestation_type VARCHAR(50) DEFAULT 'none', -- none, indirect, direct + aaguid BYTEA, -- Authenticator AAGUID + sign_count INTEGER DEFAULT 0, -- Signature counter for clone detection + clone_warning BOOLEAN DEFAULT false, -- True if cloning detected + transports TEXT[], -- Array of transports: usb, nfc, ble, internal + backup_eligible BOOLEAN DEFAULT false, -- Credential can be backed up + backup_state BOOLEAN DEFAULT false, -- Credential is currently backed up + name VARCHAR(255), -- User-friendly name for the credential + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON user_passkey_credentials(user_id); +CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON user_passkey_credentials(credential_id); + +-- ============================================ +-- Stored Procedures for Passkey Authentication +-- ============================================ + +-- 1. resolvespec_passkey_store_credential - Store a new passkey credential +-- Input: p_credential (jsonb) {user_id: int, credential_id: bytea, public_key: bytea, attestation_type: string, aaguid: bytea, sign_count: int, transports: array, backup_eligible: bool, backup_state: bool, name: string} +-- Output: p_success (bool), p_error (text), p_credential_id (int) +CREATE OR REPLACE FUNCTION resolvespec_passkey_store_credential(p_credential jsonb) +RETURNS TABLE(p_success boolean, p_error text, p_credential_id integer) AS $$ +DECLARE + v_credential_id INTEGER; + v_user_id INTEGER; + v_cred_id BYTEA; + v_public_key BYTEA; + v_attestation_type TEXT; + v_aaguid BYTEA; + v_sign_count INTEGER; + v_transports TEXT[]; + v_backup_eligible BOOLEAN; + v_backup_state BOOLEAN; + v_name TEXT; +BEGIN + -- Extract credential data + v_user_id := (p_credential->>'user_id')::integer; + v_cred_id := decode(p_credential->>'credential_id', 'base64'); + v_public_key := decode(p_credential->>'public_key', 'base64'); + v_attestation_type := COALESCE(p_credential->>'attestation_type', 'none'); + v_aaguid := decode(COALESCE(p_credential->>'aaguid', ''), 'base64'); + v_sign_count := COALESCE((p_credential->>'sign_count')::integer, 0); + v_backup_eligible := COALESCE((p_credential->>'backup_eligible')::boolean, false); + v_backup_state := COALESCE((p_credential->>'backup_state')::boolean, false); + v_name := p_credential->>'name'; + + -- Convert transports array + IF p_credential->'transports' IS NOT NULL THEN + SELECT ARRAY(SELECT jsonb_array_elements_text(p_credential->'transports')) + INTO v_transports; + END IF; + + -- Check if user exists + IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN + RETURN QUERY SELECT false, 'User not found'::text, NULL::integer; + RETURN; + END IF; + + -- Insert credential + INSERT INTO user_passkey_credentials ( + user_id, credential_id, public_key, attestation_type, aaguid, + sign_count, transports, backup_eligible, backup_state, name, created_at, last_used_at + ) + VALUES ( + v_user_id, v_cred_id, v_public_key, v_attestation_type, v_aaguid, + v_sign_count, v_transports, v_backup_eligible, v_backup_state, v_name, now(), now() + ) + RETURNING id INTO v_credential_id; + + RETURN QUERY SELECT true, NULL::text, v_credential_id; +EXCEPTION + WHEN unique_violation THEN + RETURN QUERY SELECT false, 'Credential already exists'::text, NULL::integer; + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text, NULL::integer; +END; +$$ LANGUAGE plpgsql; + +-- 2. resolvespec_passkey_get_credential - Get credential by credential_id +-- Input: p_credential_id (bytea) +-- Output: p_success (bool), p_error (text), p_credential (jsonb) +CREATE OR REPLACE FUNCTION resolvespec_passkey_get_credential(p_credential_id bytea) +RETURNS TABLE(p_success boolean, p_error text, p_credential jsonb) AS $$ +DECLARE + v_credential jsonb; +BEGIN + SELECT jsonb_build_object( + 'id', id, + 'user_id', user_id, + 'credential_id', encode(credential_id, 'base64'), + 'public_key', encode(public_key, 'base64'), + 'attestation_type', attestation_type, + 'aaguid', encode(COALESCE(aaguid, ''::bytea), 'base64'), + 'sign_count', sign_count, + 'clone_warning', clone_warning, + 'transports', COALESCE(to_jsonb(transports), '[]'::jsonb), + 'backup_eligible', backup_eligible, + 'backup_state', backup_state, + 'name', name, + 'created_at', created_at, + 'last_used_at', last_used_at + ) + INTO v_credential + FROM user_passkey_credentials + WHERE credential_id = p_credential_id; + + IF v_credential IS NULL THEN + RETURN QUERY SELECT false, 'Credential not found'::text, NULL::jsonb; + ELSE + RETURN QUERY SELECT true, NULL::text, v_credential; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- 3. resolvespec_passkey_get_user_credentials - Get all credentials for a user +-- Input: p_user_id (integer) +-- Output: p_success (bool), p_error (text), p_credentials (jsonb array) +CREATE OR REPLACE FUNCTION resolvespec_passkey_get_user_credentials(p_user_id integer) +RETURNS TABLE(p_success boolean, p_error text, p_credentials jsonb) AS $$ +DECLARE + v_credentials jsonb; +BEGIN + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', id, + 'user_id', user_id, + 'credential_id', encode(credential_id, 'base64'), + 'public_key', encode(public_key, 'base64'), + 'attestation_type', attestation_type, + 'aaguid', encode(COALESCE(aaguid, ''::bytea), 'base64'), + 'sign_count', sign_count, + 'clone_warning', clone_warning, + 'transports', COALESCE(to_jsonb(transports), '[]'::jsonb), + 'backup_eligible', backup_eligible, + 'backup_state', backup_state, + 'name', name, + 'created_at', created_at, + 'last_used_at', last_used_at + ) + ), '[]'::jsonb) + INTO v_credentials + FROM user_passkey_credentials + WHERE user_id = p_user_id + ORDER BY created_at DESC; + + RETURN QUERY SELECT true, NULL::text, v_credentials; +EXCEPTION + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text, '[]'::jsonb; +END; +$$ LANGUAGE plpgsql; + +-- 4. resolvespec_passkey_update_counter - Update sign counter and check for cloning +-- Input: p_credential_id (bytea), p_new_counter (integer) +-- Output: p_success (bool), p_error (text), p_clone_warning (bool) +CREATE OR REPLACE FUNCTION resolvespec_passkey_update_counter( + p_credential_id bytea, + p_new_counter integer +) +RETURNS TABLE(p_success boolean, p_error text, p_clone_warning boolean) AS $$ +DECLARE + v_old_counter INTEGER; + v_clone_warning BOOLEAN := false; +BEGIN + -- Get current counter + SELECT sign_count INTO v_old_counter + FROM user_passkey_credentials + WHERE credential_id = p_credential_id; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'Credential not found'::text, false; + RETURN; + END IF; + + -- Check for cloning (counter should always increase) + IF p_new_counter <= v_old_counter THEN + v_clone_warning := true; + + -- Update clone warning flag + UPDATE user_passkey_credentials + SET clone_warning = true + WHERE credential_id = p_credential_id; + ELSE + -- Normal counter update + UPDATE user_passkey_credentials + SET sign_count = p_new_counter, + last_used_at = now() + WHERE credential_id = p_credential_id; + END IF; + + RETURN QUERY SELECT true, NULL::text, v_clone_warning; +EXCEPTION + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text, false; +END; +$$ LANGUAGE plpgsql; + +-- 5. resolvespec_passkey_delete_credential - Delete a passkey credential +-- Input: p_user_id (integer), p_credential_id (bytea) +-- Output: p_success (bool), p_error (text) +CREATE OR REPLACE FUNCTION resolvespec_passkey_delete_credential( + p_user_id integer, + p_credential_id bytea +) +RETURNS TABLE(p_success boolean, p_error text) AS $$ +DECLARE + v_deleted INTEGER; +BEGIN + DELETE FROM user_passkey_credentials + WHERE user_id = p_user_id AND credential_id = p_credential_id; + + GET DIAGNOSTICS v_deleted = ROW_COUNT; + + IF v_deleted = 0 THEN + RETURN QUERY SELECT false, 'Credential not found'::text; + ELSE + RETURN QUERY SELECT true, NULL::text; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- 6. resolvespec_passkey_update_name - Update credential friendly name +-- Input: p_user_id (integer), p_credential_id (bytea), p_name (text) +-- Output: p_success (bool), p_error (text) +CREATE OR REPLACE FUNCTION resolvespec_passkey_update_name( + p_user_id integer, + p_credential_id bytea, + p_name text +) +RETURNS TABLE(p_success boolean, p_error text) AS $$ +DECLARE + v_updated INTEGER; +BEGIN + UPDATE user_passkey_credentials + SET name = p_name + WHERE user_id = p_user_id AND credential_id = p_credential_id; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + IF v_updated = 0 THEN + RETURN QUERY SELECT false, 'Credential not found'::text; + ELSE + RETURN QUERY SELECT true, NULL::text; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- 7. resolvespec_passkey_get_credentials_by_username - Get credentials for passkey authentication +-- Input: p_username (text) +-- Output: p_success (bool), p_error (text), p_user_id (int), p_credentials (jsonb array) +CREATE OR REPLACE FUNCTION resolvespec_passkey_get_credentials_by_username(p_username text) +RETURNS TABLE(p_success boolean, p_error text, p_user_id integer, p_credentials jsonb) AS $$ +DECLARE + v_user_id INTEGER; + v_credentials jsonb; +BEGIN + -- Get user ID + SELECT id INTO v_user_id + FROM users + WHERE username = p_username AND is_active = true; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 'User not found'::text, NULL::integer, NULL::jsonb; + RETURN; + END IF; + + -- Get user's credentials + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', id, + 'credential_id', encode(credential_id, 'base64'), + 'transports', COALESCE(to_jsonb(transports), '[]'::jsonb) + ) + ), '[]'::jsonb) + INTO v_credentials + FROM user_passkey_credentials + WHERE user_id = v_user_id; + + RETURN QUERY SELECT true, NULL::text, v_user_id, v_credentials; +EXCEPTION + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text, NULL::integer, NULL::jsonb; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- Example: Test Passkey stored procedures +-- ============================================ + +-- Store credential +-- SELECT * FROM resolvespec_passkey_store_credential('{"user_id": 1, "credential_id": "YWJjZGVmMTIzNDU2", "public_key": "MIIBIjAN...", "attestation_type": "none", "sign_count": 0, "transports": ["internal"], "backup_eligible": true, "backup_state": false, "name": "My Phone"}'::jsonb); + +-- Get credential +-- SELECT * FROM resolvespec_passkey_get_credential(decode('YWJjZGVmMTIzNDU2', 'base64')); + +-- Get user credentials +-- SELECT * FROM resolvespec_passkey_get_user_credentials(1); + +-- Update counter +-- SELECT * FROM resolvespec_passkey_update_counter(decode('YWJjZGVmMTIzNDU2', 'base64'), 1); + +-- Delete credential +-- SELECT * FROM resolvespec_passkey_delete_credential(1, decode('YWJjZGVmMTIzNDU2', 'base64')); + +-- Update name +-- SELECT * FROM resolvespec_passkey_update_name(1, decode('YWJjZGVmMTIzNDU2', 'base64'), 'New Name'); + +-- Get credentials by username +-- SELECT * FROM resolvespec_passkey_get_credentials_by_username('admin'); diff --git a/pkg/security/passkey.go b/pkg/security/passkey.go new file mode 100644 index 0000000..e0c49a9 --- /dev/null +++ b/pkg/security/passkey.go @@ -0,0 +1,185 @@ +package security + +import ( + "context" + "encoding/json" + "time" +) + +// PasskeyCredential represents a stored WebAuthn/FIDO2 credential +type PasskeyCredential struct { + ID string `json:"id"` + UserID int `json:"user_id"` + CredentialID []byte `json:"credential_id"` // Raw credential ID from authenticator + PublicKey []byte `json:"public_key"` // COSE public key + AttestationType string `json:"attestation_type"` // none, indirect, direct + AAGUID []byte `json:"aaguid"` // Authenticator AAGUID + SignCount uint32 `json:"sign_count"` // Signature counter + CloneWarning bool `json:"clone_warning"` // True if cloning detected + Transports []string `json:"transports,omitempty"` // usb, nfc, ble, internal + BackupEligible bool `json:"backup_eligible"` // Credential can be backed up + BackupState bool `json:"backup_state"` // Credential is currently backed up + Name string `json:"name,omitempty"` // User-friendly name + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` +} + +// PasskeyRegistrationOptions contains options for beginning passkey registration +type PasskeyRegistrationOptions struct { + Challenge []byte `json:"challenge"` + RelyingParty PasskeyRelyingParty `json:"rp"` + User PasskeyUser `json:"user"` + PubKeyCredParams []PasskeyCredentialParam `json:"pubKeyCredParams"` + Timeout int64 `json:"timeout,omitempty"` // Milliseconds + ExcludeCredentials []PasskeyCredentialDescriptor `json:"excludeCredentials,omitempty"` + AuthenticatorSelection *PasskeyAuthenticatorSelection `json:"authenticatorSelection,omitempty"` + Attestation string `json:"attestation,omitempty"` // none, indirect, direct, enterprise + Extensions map[string]any `json:"extensions,omitempty"` +} + +// PasskeyAuthenticationOptions contains options for beginning passkey authentication +type PasskeyAuthenticationOptions struct { + Challenge []byte `json:"challenge"` + Timeout int64 `json:"timeout,omitempty"` + RelyingPartyID string `json:"rpId,omitempty"` + AllowCredentials []PasskeyCredentialDescriptor `json:"allowCredentials,omitempty"` + UserVerification string `json:"userVerification,omitempty"` // required, preferred, discouraged + Extensions map[string]any `json:"extensions,omitempty"` +} + +// PasskeyRelyingParty identifies the relying party +type PasskeyRelyingParty struct { + ID string `json:"id"` // Domain (e.g., "example.com") + Name string `json:"name"` // Display name +} + +// PasskeyUser identifies the user +type PasskeyUser struct { + ID []byte `json:"id"` // User handle (unique, persistent) + Name string `json:"name"` // Username + DisplayName string `json:"displayName"` // Display name +} + +// PasskeyCredentialParam specifies supported public key algorithm +type PasskeyCredentialParam struct { + Type string `json:"type"` // "public-key" + Alg int `json:"alg"` // COSE algorithm identifier (e.g., -7 for ES256, -257 for RS256) +} + +// PasskeyCredentialDescriptor describes a credential +type PasskeyCredentialDescriptor struct { + Type string `json:"type"` // "public-key" + ID []byte `json:"id"` // Credential ID + Transports []string `json:"transports,omitempty"` // usb, nfc, ble, internal +} + +// PasskeyAuthenticatorSelection specifies authenticator requirements +type PasskeyAuthenticatorSelection struct { + AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"` // platform, cross-platform + RequireResidentKey bool `json:"requireResidentKey,omitempty"` + ResidentKey string `json:"residentKey,omitempty"` // discouraged, preferred, required + UserVerification string `json:"userVerification,omitempty"` // required, preferred, discouraged +} + +// PasskeyRegistrationResponse contains the client's registration response +type PasskeyRegistrationResponse struct { + ID string `json:"id"` // Base64URL encoded credential ID + RawID []byte `json:"rawId"` // Raw credential ID + Type string `json:"type"` // "public-key" + Response PasskeyAuthenticatorAttestationResponse `json:"response"` + ClientExtensionResults map[string]any `json:"clientExtensionResults,omitempty"` + Transports []string `json:"transports,omitempty"` +} + +// PasskeyAuthenticatorAttestationResponse contains attestation data +type PasskeyAuthenticatorAttestationResponse struct { + ClientDataJSON []byte `json:"clientDataJSON"` + AttestationObject []byte `json:"attestationObject"` + Transports []string `json:"transports,omitempty"` +} + +// PasskeyAuthenticationResponse contains the client's authentication response +type PasskeyAuthenticationResponse struct { + ID string `json:"id"` // Base64URL encoded credential ID + RawID []byte `json:"rawId"` // Raw credential ID + Type string `json:"type"` // "public-key" + Response PasskeyAuthenticatorAssertionResponse `json:"response"` + ClientExtensionResults map[string]any `json:"clientExtensionResults,omitempty"` +} + +// PasskeyAuthenticatorAssertionResponse contains assertion data +type PasskeyAuthenticatorAssertionResponse struct { + ClientDataJSON []byte `json:"clientDataJSON"` + AuthenticatorData []byte `json:"authenticatorData"` + Signature []byte `json:"signature"` + UserHandle []byte `json:"userHandle,omitempty"` +} + +// PasskeyProvider handles passkey registration and authentication +type PasskeyProvider interface { + // BeginRegistration creates registration options for a new passkey + BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) + + // CompleteRegistration verifies and stores a new passkey credential + CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error) + + // BeginAuthentication creates authentication options for passkey login + BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error) + + // CompleteAuthentication verifies a passkey assertion and returns the user + CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error) + + // GetCredentials returns all passkey credentials for a user + GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) + + // DeleteCredential removes a passkey credential + DeleteCredential(ctx context.Context, userID int, credentialID string) error + + // UpdateCredentialName updates the friendly name of a credential + UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error +} + +// PasskeyLoginRequest contains passkey authentication data +type PasskeyLoginRequest struct { + Response PasskeyAuthenticationResponse `json:"response"` + ExpectedChallenge []byte `json:"expected_challenge"` + Claims map[string]any `json:"claims"` // Additional login data +} + +// PasskeyRegisterRequest contains passkey registration data +type PasskeyRegisterRequest struct { + UserID int `json:"user_id"` + Response PasskeyRegistrationResponse `json:"response"` + ExpectedChallenge []byte `json:"expected_challenge"` + CredentialName string `json:"credential_name,omitempty"` +} + +// PasskeyBeginRegistrationRequest contains options for starting passkey registration +type PasskeyBeginRegistrationRequest struct { + UserID int `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` +} + +// PasskeyBeginAuthenticationRequest contains options for starting passkey authentication +type PasskeyBeginAuthenticationRequest struct { + Username string `json:"username,omitempty"` // Optional for resident key flow +} + +// ParsePasskeyRegistrationResponse parses a JSON passkey registration response +func ParsePasskeyRegistrationResponse(data []byte) (*PasskeyRegistrationResponse, error) { + var response PasskeyRegistrationResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + return &response, nil +} + +// ParsePasskeyAuthenticationResponse parses a JSON passkey authentication response +func ParsePasskeyAuthenticationResponse(data []byte) (*PasskeyAuthenticationResponse, error) { + var response PasskeyAuthenticationResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/pkg/security/passkey_examples.go b/pkg/security/passkey_examples.go new file mode 100644 index 0000000..d2316b4 --- /dev/null +++ b/pkg/security/passkey_examples.go @@ -0,0 +1,431 @@ +package security + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" +) + +// PasskeyAuthenticationExample demonstrates passkey (WebAuthn/FIDO2) authentication +func PasskeyAuthenticationExample() { + // Setup database connection + db, _ := sql.Open("postgres", "postgres://user:pass@localhost/db") + + // Create passkey provider + passkeyProvider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", // Your domain + RPName: "Example Application", // Display name + RPOrigin: "https://example.com", // Expected origin + Timeout: 60000, // 60 seconds + }) + + // Create authenticator with passkey support + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{ + PasskeyProvider: passkeyProvider, + }) + + // Or use WithPasskey method + auth = NewDatabaseAuthenticator(db).WithPasskey(passkeyProvider) + + ctx := context.Background() + + // === REGISTRATION FLOW === + + // Step 1: Begin registration + regOptions, _ := auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{ + UserID: 1, + Username: "alice", + DisplayName: "Alice Smith", + }) + + // Send regOptions to client as JSON + // Client will call navigator.credentials.create() with these options + _ = regOptions + + // Step 2: Complete registration (after client returns credential) + // This would come from the client's navigator.credentials.create() response + clientResponse := PasskeyRegistrationResponse{ + ID: "base64-credential-id", + RawID: []byte("raw-credential-id"), + Type: "public-key", + Response: PasskeyAuthenticatorAttestationResponse{ + ClientDataJSON: []byte("..."), + AttestationObject: []byte("..."), + }, + Transports: []string{"internal"}, + } + + credential, _ := auth.CompletePasskeyRegistration(ctx, PasskeyRegisterRequest{ + UserID: 1, + Response: clientResponse, + ExpectedChallenge: regOptions.Challenge, + CredentialName: "My iPhone", + }) + + fmt.Printf("Registered credential: %s\n", credential.ID) + + // === AUTHENTICATION FLOW === + + // Step 1: Begin authentication + authOptions, _ := auth.BeginPasskeyAuthentication(ctx, PasskeyBeginAuthenticationRequest{ + Username: "alice", // Optional - omit for resident key flow + }) + + // Send authOptions to client as JSON + // Client will call navigator.credentials.get() with these options + _ = authOptions + + // Step 2: Complete authentication (after client returns assertion) + // This would come from the client's navigator.credentials.get() response + clientAssertion := PasskeyAuthenticationResponse{ + ID: "base64-credential-id", + RawID: []byte("raw-credential-id"), + Type: "public-key", + Response: PasskeyAuthenticatorAssertionResponse{ + ClientDataJSON: []byte("..."), + AuthenticatorData: []byte("..."), + Signature: []byte("..."), + }, + } + + loginResponse, _ := auth.LoginWithPasskey(ctx, PasskeyLoginRequest{ + Response: clientAssertion, + ExpectedChallenge: authOptions.Challenge, + Claims: map[string]any{ + "ip_address": "192.168.1.1", + "user_agent": "Mozilla/5.0...", + }, + }) + + fmt.Printf("Logged in user: %s with token: %s\n", + loginResponse.User.UserName, loginResponse.Token) + + // === CREDENTIAL MANAGEMENT === + + // Get all credentials for a user + credentials, _ := auth.GetPasskeyCredentials(ctx, 1) + for _, cred := range credentials { + fmt.Printf("Credential: %s (created: %s, last used: %s)\n", + cred.Name, cred.CreatedAt, cred.LastUsedAt) + } + + // Update credential name + _ = auth.UpdatePasskeyCredentialName(ctx, 1, credential.ID, "My New iPhone") + + // Delete credential + _ = auth.DeletePasskeyCredential(ctx, 1, credential.ID) +} + +// PasskeyHTTPHandlersExample shows HTTP handlers for passkey authentication +func PasskeyHTTPHandlersExample(auth *DatabaseAuthenticator) { + // Store challenges in session/cache in production + challenges := make(map[string][]byte) + + // Begin registration endpoint + http.HandleFunc("/api/passkey/register/begin", func(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + } + json.NewDecoder(r.Body).Decode(&req) + + options, err := auth.BeginPasskeyRegistration(r.Context(), PasskeyBeginRegistrationRequest{ + UserID: req.UserID, + Username: req.Username, + DisplayName: req.DisplayName, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Store challenge for verification (use session ID as key in production) + sessionID := "session-123" + challenges[sessionID] = options.Challenge + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(options) + }) + + // Complete registration endpoint + http.HandleFunc("/api/passkey/register/complete", func(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int `json:"user_id"` + Response PasskeyRegistrationResponse `json:"response"` + CredentialName string `json:"credential_name"` + } + json.NewDecoder(r.Body).Decode(&req) + + // Get stored challenge (from session in production) + sessionID := "session-123" + challenge := challenges[sessionID] + delete(challenges, sessionID) + + credential, err := auth.CompletePasskeyRegistration(r.Context(), PasskeyRegisterRequest{ + UserID: req.UserID, + Response: req.Response, + ExpectedChallenge: challenge, + CredentialName: req.CredentialName, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(credential) + }) + + // Begin authentication endpoint + http.HandleFunc("/api/passkey/login/begin", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` // Optional + } + json.NewDecoder(r.Body).Decode(&req) + + options, err := auth.BeginPasskeyAuthentication(r.Context(), PasskeyBeginAuthenticationRequest{ + Username: req.Username, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Store challenge for verification (use session ID as key in production) + sessionID := "session-456" + challenges[sessionID] = options.Challenge + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(options) + }) + + // Complete authentication endpoint + http.HandleFunc("/api/passkey/login/complete", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Response PasskeyAuthenticationResponse `json:"response"` + } + json.NewDecoder(r.Body).Decode(&req) + + // Get stored challenge (from session in production) + sessionID := "session-456" + challenge := challenges[sessionID] + delete(challenges, sessionID) + + loginResponse, err := auth.LoginWithPasskey(r.Context(), PasskeyLoginRequest{ + Response: req.Response, + ExpectedChallenge: challenge, + Claims: map[string]any{ + "ip_address": r.RemoteAddr, + "user_agent": r.UserAgent(), + }, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: loginResponse.Token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(loginResponse) + }) + + // List credentials endpoint + http.HandleFunc("/api/passkey/credentials", func(w http.ResponseWriter, r *http.Request) { + // Get user from authenticated session + userCtx, err := auth.Authenticate(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + credentials, err := auth.GetPasskeyCredentials(r.Context(), userCtx.UserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(credentials) + }) + + // Delete credential endpoint + http.HandleFunc("/api/passkey/credentials/delete", func(w http.ResponseWriter, r *http.Request) { + userCtx, err := auth.Authenticate(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + CredentialID string `json:"credential_id"` + } + json.NewDecoder(r.Body).Decode(&req) + + err = auth.DeletePasskeyCredential(r.Context(), userCtx.UserID, req.CredentialID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + }) +} + +// PasskeyClientSideExample shows the client-side JavaScript code needed +func PasskeyClientSideExample() string { + return ` +// === CLIENT-SIDE JAVASCRIPT FOR PASSKEY AUTHENTICATION === + +// Helper function to convert base64 to ArrayBuffer +function base64ToArrayBuffer(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +// Helper function to convert ArrayBuffer to base64 +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +// === REGISTRATION === + +async function registerPasskey(userId, username, displayName) { + // Step 1: Get registration options from server + const optionsResponse = await fetch('/api/passkey/register/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, username, display_name: displayName }) + }); + const options = await optionsResponse.json(); + + // Convert base64 strings to ArrayBuffers + options.challenge = base64ToArrayBuffer(options.challenge); + options.user.id = base64ToArrayBuffer(options.user.id); + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map(cred => ({ + ...cred, + id: base64ToArrayBuffer(cred.id) + })); + } + + // Step 2: Create credential using WebAuthn API + const credential = await navigator.credentials.create({ + publicKey: options + }); + + // Step 3: Send credential to server + const credentialResponse = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + attestationObject: arrayBufferToBase64(credential.response.attestationObject) + }, + transports: credential.response.getTransports ? credential.response.getTransports() : [] + }; + + const completeResponse = await fetch('/api/passkey/register/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: userId, + response: credentialResponse, + credential_name: 'My Device' + }) + }); + + return await completeResponse.json(); +} + +// === AUTHENTICATION === + +async function loginWithPasskey(username) { + // Step 1: Get authentication options from server + const optionsResponse = await fetch('/api/passkey/login/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }) + }); + const options = await optionsResponse.json(); + + // Convert base64 strings to ArrayBuffers + options.challenge = base64ToArrayBuffer(options.challenge); + if (options.allowCredentials) { + options.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: base64ToArrayBuffer(cred.id) + })); + } + + // Step 2: Get credential using WebAuthn API + const credential = await navigator.credentials.get({ + publicKey: options + }); + + // Step 3: Send assertion to server + const assertionResponse = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + authenticatorData: arrayBufferToBase64(credential.response.authenticatorData), + signature: arrayBufferToBase64(credential.response.signature), + userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null + } + }; + + const loginResponse = await fetch('/api/passkey/login/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response: assertionResponse }) + }); + + return await loginResponse.json(); +} + +// === USAGE === + +// Register a new passkey +document.getElementById('register-btn').addEventListener('click', async () => { + try { + const result = await registerPasskey(1, 'alice', 'Alice Smith'); + console.log('Passkey registered:', result); + } catch (error) { + console.error('Registration failed:', error); + } +}); + +// Login with passkey +document.getElementById('login-btn').addEventListener('click', async () => { + try { + const result = await loginWithPasskey('alice'); + console.log('Logged in:', result); + } catch (error) { + console.error('Login failed:', error); + } +}); +` +} diff --git a/pkg/security/passkey_provider.go b/pkg/security/passkey_provider.go new file mode 100644 index 0000000..f4ac144 --- /dev/null +++ b/pkg/security/passkey_provider.go @@ -0,0 +1,404 @@ +package security + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "time" +) + +// DatabasePasskeyProvider implements PasskeyProvider using database storage +type DatabasePasskeyProvider struct { + db *sql.DB + rpID string // Relying Party ID (domain) + rpName string // Relying Party display name + rpOrigin string // Expected origin for WebAuthn + timeout int64 // Timeout in milliseconds (default: 60000) +} + +// DatabasePasskeyProviderOptions configures the passkey provider +type DatabasePasskeyProviderOptions struct { + // RPID is the Relying Party ID (typically your domain, e.g., "example.com") + RPID string + // RPName is the display name for your relying party + RPName string + // RPOrigin is the expected origin (e.g., "https://example.com") + RPOrigin string + // Timeout is the timeout for operations in milliseconds (default: 60000) + Timeout int64 +} + +// NewDatabasePasskeyProvider creates a new database-backed passkey provider +func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions) *DatabasePasskeyProvider { + if opts.Timeout == 0 { + opts.Timeout = 60000 // 60 seconds default + } + + return &DatabasePasskeyProvider{ + db: db, + rpID: opts.RPID, + rpName: opts.RPName, + rpOrigin: opts.RPOrigin, + timeout: opts.Timeout, + } +} + +// BeginRegistration creates registration options for a new passkey +func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) { + // Generate challenge + challenge := make([]byte, 32) + if _, err := rand.Read(challenge); err != nil { + return nil, fmt.Errorf("failed to generate challenge: %w", err) + } + + // Get existing credentials to exclude + credentials, err := p.GetCredentials(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get existing credentials: %w", err) + } + + excludeCredentials := make([]PasskeyCredentialDescriptor, 0, len(credentials)) + for _, cred := range credentials { + excludeCredentials = append(excludeCredentials, PasskeyCredentialDescriptor{ + Type: "public-key", + ID: cred.CredentialID, + Transports: cred.Transports, + }) + } + + // Create user handle (persistent user ID) + userHandle := []byte(fmt.Sprintf("user_%d", userID)) + + return &PasskeyRegistrationOptions{ + Challenge: challenge, + RelyingParty: PasskeyRelyingParty{ + ID: p.rpID, + Name: p.rpName, + }, + User: PasskeyUser{ + ID: userHandle, + Name: username, + DisplayName: displayName, + }, + PubKeyCredParams: []PasskeyCredentialParam{ + {Type: "public-key", Alg: -7}, // ES256 (ECDSA with SHA-256) + {Type: "public-key", Alg: -257}, // RS256 (RSASSA-PKCS1-v1_5 with SHA-256) + }, + Timeout: p.timeout, + ExcludeCredentials: excludeCredentials, + AuthenticatorSelection: &PasskeyAuthenticatorSelection{ + RequireResidentKey: false, + ResidentKey: "preferred", + UserVerification: "preferred", + }, + Attestation: "none", + }, nil +} + +// CompleteRegistration verifies and stores a new passkey credential +// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library +// like github.com/go-webauthn/webauthn to properly verify attestation and parse credentials. +func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, userID int, response PasskeyRegistrationResponse, expectedChallenge []byte) (*PasskeyCredential, error) { + // TODO: Implement full WebAuthn verification + // 1. Verify clientDataJSON contains correct challenge and origin + // 2. Parse and verify attestationObject + // 3. Extract public key and credential ID + // 4. Verify attestation signature (if not "none") + + // For now, this is a placeholder that stores the credential data + // In production, you MUST use a proper WebAuthn library + + credData := map[string]any{ + "user_id": userID, + "credential_id": base64.StdEncoding.EncodeToString(response.RawID), + "public_key": base64.StdEncoding.EncodeToString(response.Response.AttestationObject), + "attestation_type": "none", + "sign_count": 0, + "transports": response.Transports, + "backup_eligible": false, + "backup_state": false, + "name": "Passkey", + } + + credJSON, err := json.Marshal(credData) + if err != nil { + return nil, fmt.Errorf("failed to marshal credential data: %w", err) + } + + var success bool + var errorMsg sql.NullString + var credentialID sql.NullInt64 + + query := `SELECT p_success, p_error, p_credential_id FROM resolvespec_passkey_store_credential($1::jsonb)` + err = p.db.QueryRowContext(ctx, query, string(credJSON)).Scan(&success, &errorMsg, &credentialID) + if err != nil { + return nil, fmt.Errorf("failed to store credential: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("failed to store credential") + } + + return &PasskeyCredential{ + ID: fmt.Sprintf("%d", credentialID.Int64), + UserID: userID, + CredentialID: response.RawID, + PublicKey: response.Response.AttestationObject, + AttestationType: "none", + Transports: response.Transports, + CreatedAt: time.Now(), + LastUsedAt: time.Now(), + }, nil +} + +// BeginAuthentication creates authentication options for passkey login +func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, username string) (*PasskeyAuthenticationOptions, error) { + // Generate challenge + challenge := make([]byte, 32) + if _, err := rand.Read(challenge); err != nil { + return nil, fmt.Errorf("failed to generate challenge: %w", err) + } + + // If username is provided, get user's credentials + var allowCredentials []PasskeyCredentialDescriptor + if username != "" { + var success bool + var errorMsg sql.NullString + var userID sql.NullInt64 + var credentialsJSON sql.NullString + + query := `SELECT p_success, p_error, p_user_id, p_credentials::text FROM resolvespec_passkey_get_credentials_by_username($1)` + err := p.db.QueryRowContext(ctx, query, username).Scan(&success, &errorMsg, &userID, &credentialsJSON) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("failed to get credentials") + } + + // Parse credentials + var creds []struct { + ID string `json:"credential_id"` + Transports []string `json:"transports"` + } + if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds)) + for _, cred := range creds { + credID, err := base64.StdEncoding.DecodeString(cred.ID) + if err != nil { + continue + } + allowCredentials = append(allowCredentials, PasskeyCredentialDescriptor{ + Type: "public-key", + ID: credID, + Transports: cred.Transports, + }) + } + } + + return &PasskeyAuthenticationOptions{ + Challenge: challenge, + Timeout: p.timeout, + RelyingPartyID: p.rpID, + AllowCredentials: allowCredentials, + UserVerification: "preferred", + }, nil +} + +// CompleteAuthentication verifies a passkey assertion and returns the user ID +// NOTE: This is a simplified implementation. In production, you should use a WebAuthn library +// like github.com/go-webauthn/webauthn to properly verify the assertion signature. +func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, response PasskeyAuthenticationResponse, expectedChallenge []byte) (int, error) { + // TODO: Implement full WebAuthn verification + // 1. Verify clientDataJSON contains correct challenge and origin + // 2. Verify authenticatorData + // 3. Verify signature using stored public key + // 4. Update sign counter and check for cloning + + // Get credential from database + var success bool + var errorMsg sql.NullString + var credentialJSON sql.NullString + + query := `SELECT p_success, p_error, p_credential::text FROM resolvespec_passkey_get_credential($1)` + err := p.db.QueryRowContext(ctx, query, response.RawID).Scan(&success, &errorMsg, &credentialJSON) + if err != nil { + return 0, fmt.Errorf("failed to get credential: %w", err) + } + + if !success { + if errorMsg.Valid { + return 0, fmt.Errorf("%s", errorMsg.String) + } + return 0, fmt.Errorf("credential not found") + } + + // Parse credential + var cred struct { + UserID int `json:"user_id"` + SignCount uint32 `json:"sign_count"` + } + if err := json.Unmarshal([]byte(credentialJSON.String), &cred); err != nil { + return 0, fmt.Errorf("failed to parse credential: %w", err) + } + + // TODO: Verify signature here + // For now, we'll just update the counter as a placeholder + + // Update counter (in production, this should be done after successful verification) + newCounter := cred.SignCount + 1 + var updateSuccess bool + var updateError sql.NullString + var cloneWarning sql.NullBool + + updateQuery := `SELECT p_success, p_error, p_clone_warning FROM resolvespec_passkey_update_counter($1, $2)` + err = p.db.QueryRowContext(ctx, updateQuery, response.RawID, newCounter).Scan(&updateSuccess, &updateError, &cloneWarning) + if err != nil { + return 0, fmt.Errorf("failed to update counter: %w", err) + } + + if cloneWarning.Valid && cloneWarning.Bool { + return 0, fmt.Errorf("credential cloning detected") + } + + return cred.UserID, nil +} + +// GetCredentials returns all passkey credentials for a user +func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) { + var success bool + var errorMsg sql.NullString + var credentialsJSON sql.NullString + + query := `SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials($1)` + err := p.db.QueryRowContext(ctx, query, userID).Scan(&success, &errorMsg, &credentialsJSON) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("failed to get credentials") + } + + // Parse credentials + var rawCreds []struct { + ID int `json:"id"` + UserID int `json:"user_id"` + CredentialID string `json:"credential_id"` + PublicKey string `json:"public_key"` + AttestationType string `json:"attestation_type"` + AAGUID string `json:"aaguid"` + SignCount uint32 `json:"sign_count"` + CloneWarning bool `json:"clone_warning"` + Transports []string `json:"transports"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` + } + + if err := json.Unmarshal([]byte(credentialsJSON.String), &rawCreds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + credentials := make([]PasskeyCredential, 0, len(rawCreds)) + for _, raw := range rawCreds { + credID, err := base64.StdEncoding.DecodeString(raw.CredentialID) + if err != nil { + continue + } + pubKey, err := base64.StdEncoding.DecodeString(raw.PublicKey) + if err != nil { + continue + } + aaguid, _ := base64.StdEncoding.DecodeString(raw.AAGUID) + + credentials = append(credentials, PasskeyCredential{ + ID: fmt.Sprintf("%d", raw.ID), + UserID: raw.UserID, + CredentialID: credID, + PublicKey: pubKey, + AttestationType: raw.AttestationType, + AAGUID: aaguid, + SignCount: raw.SignCount, + CloneWarning: raw.CloneWarning, + Transports: raw.Transports, + BackupEligible: raw.BackupEligible, + BackupState: raw.BackupState, + Name: raw.Name, + CreatedAt: raw.CreatedAt, + LastUsedAt: raw.LastUsedAt, + }) + } + + return credentials, nil +} + +// DeleteCredential removes a passkey credential +func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID int, credentialID string) error { + credID, err := base64.StdEncoding.DecodeString(credentialID) + if err != nil { + return fmt.Errorf("invalid credential ID: %w", err) + } + + var success bool + var errorMsg sql.NullString + + query := `SELECT p_success, p_error FROM resolvespec_passkey_delete_credential($1, $2)` + err = p.db.QueryRowContext(ctx, query, userID, credID).Scan(&success, &errorMsg) + if err != nil { + return fmt.Errorf("failed to delete credential: %w", err) + } + + if !success { + if errorMsg.Valid { + return fmt.Errorf("%s", errorMsg.String) + } + return fmt.Errorf("failed to delete credential") + } + + return nil +} + +// UpdateCredentialName updates the friendly name of a credential +func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, userID int, credentialID string, name string) error { + credID, err := base64.StdEncoding.DecodeString(credentialID) + if err != nil { + return fmt.Errorf("invalid credential ID: %w", err) + } + + var success bool + var errorMsg sql.NullString + + query := `SELECT p_success, p_error FROM resolvespec_passkey_update_name($1, $2, $3)` + err = p.db.QueryRowContext(ctx, query, userID, credID, name).Scan(&success, &errorMsg) + if err != nil { + return fmt.Errorf("failed to update credential name: %w", err) + } + + if !success { + if errorMsg.Valid { + return fmt.Errorf("%s", errorMsg.String) + } + return fmt.Errorf("failed to update credential name") + } + + return nil +} diff --git a/pkg/security/passkey_test.go b/pkg/security/passkey_test.go new file mode 100644 index 0000000..1669c73 --- /dev/null +++ b/pkg/security/passkey_test.go @@ -0,0 +1,330 @@ +package security + +import ( + "context" + "database/sql" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestDatabasePasskeyProvider_BeginRegistration(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + RPOrigin: "https://example.com", + }) + + ctx := context.Background() + + // Mock get credentials query + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}). + AddRow(true, nil, "[]") + mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`). + WithArgs(1). + WillReturnRows(rows) + + opts, err := provider.BeginRegistration(ctx, 1, "testuser", "Test User") + if err != nil { + t.Fatalf("BeginRegistration failed: %v", err) + } + + if opts.RelyingParty.ID != "example.com" { + t.Errorf("expected RP ID 'example.com', got '%s'", opts.RelyingParty.ID) + } + + if opts.User.Name != "testuser" { + t.Errorf("expected username 'testuser', got '%s'", opts.User.Name) + } + + if len(opts.Challenge) != 32 { + t.Errorf("expected challenge length 32, got %d", len(opts.Challenge)) + } + + if len(opts.PubKeyCredParams) != 2 { + t.Errorf("expected 2 credential params, got %d", len(opts.PubKeyCredParams)) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestDatabasePasskeyProvider_BeginAuthentication(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + RPOrigin: "https://example.com", + }) + + ctx := context.Background() + + // Mock get credentials by username query + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_user_id", "p_credentials"}). + AddRow(true, nil, 1, `[{"credential_id":"YWJjZGVm","transports":["internal"]}]`) + mock.ExpectQuery(`SELECT p_success, p_error, p_user_id, p_credentials::text FROM resolvespec_passkey_get_credentials_by_username`). + WithArgs("testuser"). + WillReturnRows(rows) + + opts, err := provider.BeginAuthentication(ctx, "testuser") + if err != nil { + t.Fatalf("BeginAuthentication failed: %v", err) + } + + if opts.RelyingPartyID != "example.com" { + t.Errorf("expected RP ID 'example.com', got '%s'", opts.RelyingPartyID) + } + + if len(opts.Challenge) != 32 { + t.Errorf("expected challenge length 32, got %d", len(opts.Challenge)) + } + + if len(opts.AllowCredentials) != 1 { + t.Errorf("expected 1 allowed credential, got %d", len(opts.AllowCredentials)) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestDatabasePasskeyProvider_GetCredentials(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + }) + + ctx := context.Background() + + credentialsJSON := `[{ + "id": 1, + "user_id": 1, + "credential_id": "YWJjZGVmMTIzNDU2", + "public_key": "cHVibGlja2V5", + "attestation_type": "none", + "aaguid": "", + "sign_count": 5, + "clone_warning": false, + "transports": ["internal"], + "backup_eligible": true, + "backup_state": false, + "name": "My Phone", + "created_at": "2026-01-01T00:00:00Z", + "last_used_at": "2026-01-31T00:00:00Z" + }]` + + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}). + AddRow(true, nil, credentialsJSON) + mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`). + WithArgs(1). + WillReturnRows(rows) + + credentials, err := provider.GetCredentials(ctx, 1) + if err != nil { + t.Fatalf("GetCredentials failed: %v", err) + } + + if len(credentials) != 1 { + t.Fatalf("expected 1 credential, got %d", len(credentials)) + } + + cred := credentials[0] + if cred.UserID != 1 { + t.Errorf("expected user ID 1, got %d", cred.UserID) + } + if cred.Name != "My Phone" { + t.Errorf("expected name 'My Phone', got '%s'", cred.Name) + } + if cred.SignCount != 5 { + t.Errorf("expected sign count 5, got %d", cred.SignCount) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestDatabasePasskeyProvider_DeleteCredential(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + }) + + ctx := context.Background() + + rows := sqlmock.NewRows([]string{"p_success", "p_error"}). + AddRow(true, nil) + mock.ExpectQuery(`SELECT p_success, p_error FROM resolvespec_passkey_delete_credential`). + WithArgs(1, sqlmock.AnyArg()). + WillReturnRows(rows) + + err = provider.DeleteCredential(ctx, 1, "YWJjZGVmMTIzNDU2") + if err != nil { + t.Errorf("DeleteCredential failed: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestDatabasePasskeyProvider_UpdateCredentialName(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + }) + + ctx := context.Background() + + rows := sqlmock.NewRows([]string{"p_success", "p_error"}). + AddRow(true, nil) + mock.ExpectQuery(`SELECT p_success, p_error FROM resolvespec_passkey_update_name`). + WithArgs(1, sqlmock.AnyArg(), "New Name"). + WillReturnRows(rows) + + err = provider.UpdateCredentialName(ctx, 1, "YWJjZGVmMTIzNDU2", "New Name") + if err != nil { + t.Errorf("UpdateCredentialName failed: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestDatabaseAuthenticator_PasskeyMethods(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + passkeyProvider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + }) + + auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{ + PasskeyProvider: passkeyProvider, + }) + + ctx := context.Background() + + t.Run("BeginPasskeyRegistration", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}). + AddRow(true, nil, "[]") + mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`). + WithArgs(1). + WillReturnRows(rows) + + opts, err := auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{ + UserID: 1, + Username: "testuser", + DisplayName: "Test User", + }) + + if err != nil { + t.Errorf("BeginPasskeyRegistration failed: %v", err) + } + + if opts == nil { + t.Error("expected options, got nil") + } + }) + + t.Run("GetPasskeyCredentials", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_credentials"}). + AddRow(true, nil, "[]") + mock.ExpectQuery(`SELECT p_success, p_error, p_credentials::text FROM resolvespec_passkey_get_user_credentials`). + WithArgs(1). + WillReturnRows(rows) + + credentials, err := auth.GetPasskeyCredentials(ctx, 1) + if err != nil { + t.Errorf("GetPasskeyCredentials failed: %v", err) + } + + if credentials == nil { + t.Error("expected credentials slice, got nil") + } + }) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } +} + +func TestDatabaseAuthenticator_WithoutPasskey(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + auth := NewDatabaseAuthenticator(db) + ctx := context.Background() + + _, err = auth.BeginPasskeyRegistration(ctx, PasskeyBeginRegistrationRequest{ + UserID: 1, + Username: "testuser", + DisplayName: "Test User", + }) + + if err == nil { + t.Error("expected error when passkey provider not configured, got nil") + } + + expectedMsg := "passkey provider not configured" + if err.Error() != expectedMsg { + t.Errorf("expected error '%s', got '%s'", expectedMsg, err.Error()) + } +} + +func TestPasskeyProvider_NilDB(t *testing.T) { + // This test verifies that the provider can be created with nil DB + // but operations will fail. In production, always provide a valid DB. + var db *sql.DB + provider := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{ + RPID: "example.com", + RPName: "Example App", + }) + + if provider == nil { + t.Error("expected provider to be created even with nil DB") + } + + // Verify that the provider has the correct configuration + if provider.rpID != "example.com" { + t.Errorf("expected RP ID 'example.com', got '%s'", provider.rpID) + } +} diff --git a/pkg/security/providers.go b/pkg/security/providers.go index b5892cb..80cce64 100644 --- a/pkg/security/providers.go +++ b/pkg/security/providers.go @@ -62,6 +62,7 @@ func (a *HeaderAuthenticator) Authenticate(r *http.Request) (*UserContext, error // resolvespec_session_update, resolvespec_refresh_token // See database_schema.sql for procedure definitions // Also supports multiple OAuth2 providers configured with WithOAuth2() +// Also supports passkey authentication configured with WithPasskey() type DatabaseAuthenticator struct { db *sql.DB cache *cache.Cache @@ -70,6 +71,9 @@ type DatabaseAuthenticator struct { // OAuth2 providers registry (multiple providers supported) oauth2Providers map[string]*OAuth2Provider oauth2ProvidersMutex sync.RWMutex + + // Passkey provider (optional) + passkeyProvider PasskeyProvider } // DatabaseAuthenticatorOptions configures the database authenticator @@ -79,6 +83,8 @@ type DatabaseAuthenticatorOptions struct { CacheTTL time.Duration // Cache is an optional cache instance. If nil, uses the default cache Cache *cache.Cache + // PasskeyProvider is an optional passkey provider for WebAuthn/FIDO2 authentication + PasskeyProvider PasskeyProvider } func NewDatabaseAuthenticator(db *sql.DB) *DatabaseAuthenticator { @@ -98,9 +104,10 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO } return &DatabaseAuthenticator{ - db: db, - cache: cacheInstance, - cacheTTL: opts.CacheTTL, + db: db, + cache: cacheInstance, + cacheTTL: opts.CacheTTL, + passkeyProvider: opts.PasskeyProvider, } } @@ -695,3 +702,135 @@ func generateRandomString(length int) string { // } // return "" // } + +// Passkey authentication methods +// ============================== + +// WithPasskey configures the DatabaseAuthenticator with a passkey provider +func (a *DatabaseAuthenticator) WithPasskey(provider PasskeyProvider) *DatabaseAuthenticator { + a.passkeyProvider = provider + return a +} + +// BeginPasskeyRegistration initiates passkey registration for a user +func (a *DatabaseAuthenticator) BeginPasskeyRegistration(ctx context.Context, req PasskeyBeginRegistrationRequest) (*PasskeyRegistrationOptions, error) { + if a.passkeyProvider == nil { + return nil, fmt.Errorf("passkey provider not configured") + } + return a.passkeyProvider.BeginRegistration(ctx, req.UserID, req.Username, req.DisplayName) +} + +// CompletePasskeyRegistration completes passkey registration +func (a *DatabaseAuthenticator) CompletePasskeyRegistration(ctx context.Context, req PasskeyRegisterRequest) (*PasskeyCredential, error) { + if a.passkeyProvider == nil { + return nil, fmt.Errorf("passkey provider not configured") + } + + cred, err := a.passkeyProvider.CompleteRegistration(ctx, req.UserID, req.Response, req.ExpectedChallenge) + if err != nil { + return nil, err + } + + // Update credential name if provided + if req.CredentialName != "" && cred.ID != "" { + _ = a.passkeyProvider.UpdateCredentialName(ctx, req.UserID, cred.ID, req.CredentialName) + } + + return cred, nil +} + +// BeginPasskeyAuthentication initiates passkey authentication +func (a *DatabaseAuthenticator) BeginPasskeyAuthentication(ctx context.Context, req PasskeyBeginAuthenticationRequest) (*PasskeyAuthenticationOptions, error) { + if a.passkeyProvider == nil { + return nil, fmt.Errorf("passkey provider not configured") + } + return a.passkeyProvider.BeginAuthentication(ctx, req.Username) +} + +// LoginWithPasskey authenticates a user using a passkey and creates a session +func (a *DatabaseAuthenticator) LoginWithPasskey(ctx context.Context, req PasskeyLoginRequest) (*LoginResponse, error) { + if a.passkeyProvider == nil { + return nil, fmt.Errorf("passkey provider not configured") + } + + // Verify passkey assertion + userID, err := a.passkeyProvider.CompleteAuthentication(ctx, req.Response, req.ExpectedChallenge) + if err != nil { + return nil, fmt.Errorf("passkey authentication failed: %w", err) + } + + // Get user data from database + var username, email, roles string + var userLevel int + query := `SELECT username, email, user_level, COALESCE(roles, '') FROM users WHERE id = $1 AND is_active = true` + err = a.db.QueryRowContext(ctx, query, userID).Scan(&username, &email, &userLevel, &roles) + if err != nil { + return nil, fmt.Errorf("failed to get user data: %w", err) + } + + // Generate session token + sessionToken := "sess_" + generateRandomString(32) + "_" + fmt.Sprintf("%d", time.Now().Unix()) + expiresAt := time.Now().Add(24 * time.Hour) + + // Extract IP and user agent from claims + ipAddress := "" + userAgent := "" + if req.Claims != nil { + if ip, ok := req.Claims["ip_address"].(string); ok { + ipAddress = ip + } + if ua, ok := req.Claims["user_agent"].(string); ok { + userAgent = ua + } + } + + // Create session + insertQuery := `INSERT INTO user_sessions (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at) + VALUES ($1, $2, $3, $4, $5, now())` + _, err = a.db.ExecContext(ctx, insertQuery, sessionToken, userID, expiresAt, ipAddress, userAgent) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + // Update last login + updateQuery := `UPDATE users SET last_login_at = now() WHERE id = $1` + _, _ = a.db.ExecContext(ctx, updateQuery, userID) + + // Return login response + return &LoginResponse{ + Token: sessionToken, + User: &UserContext{ + UserID: userID, + UserName: username, + Email: email, + UserLevel: userLevel, + SessionID: sessionToken, + Roles: parseRoles(roles), + }, + ExpiresIn: int64(24 * time.Hour.Seconds()), + }, nil +} + +// GetPasskeyCredentials returns all passkey credentials for a user +func (a *DatabaseAuthenticator) GetPasskeyCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) { + if a.passkeyProvider == nil { + return nil, fmt.Errorf("passkey provider not configured") + } + return a.passkeyProvider.GetCredentials(ctx, userID) +} + +// DeletePasskeyCredential removes a passkey credential +func (a *DatabaseAuthenticator) DeletePasskeyCredential(ctx context.Context, userID int, credentialID string) error { + if a.passkeyProvider == nil { + return fmt.Errorf("passkey provider not configured") + } + return a.passkeyProvider.DeleteCredential(ctx, userID, credentialID) +} + +// UpdatePasskeyCredentialName updates the friendly name of a credential +func (a *DatabaseAuthenticator) UpdatePasskeyCredentialName(ctx context.Context, userID int, credentialID string, name string) error { + if a.passkeyProvider == nil { + return fmt.Errorf("passkey provider not configured") + } + return a.passkeyProvider.UpdateCredentialName(ctx, userID, credentialID, name) +}