207 lines
6.3 KiB
Markdown
207 lines
6.3 KiB
Markdown
# OIDC Account Linking Implementation
|
|
|
|
## Overview
|
|
|
|
This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts.
|
|
|
|
## Architecture
|
|
|
|
### Key Components
|
|
|
|
#### 1. Security Fix: `lib/accounts/user.ex`
|
|
|
|
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
|
|
|
```elixir
|
|
read :sign_in_with_rauthy do
|
|
argument :user_info, :map, allow_nil?: false
|
|
argument :oauth_tokens, :map, allow_nil?: false
|
|
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
|
# SECURITY: Filter by oidc_id, NOT by email!
|
|
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
|
end
|
|
```
|
|
|
|
**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts.
|
|
|
|
#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex`
|
|
|
|
Custom error raised when OIDC login conflicts with existing password account.
|
|
|
|
**Fields**:
|
|
|
|
- `user_id`: ID of the existing user
|
|
- `oidc_user_info`: OIDC user information for account linking
|
|
|
|
#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex`
|
|
|
|
Validates email uniqueness during OIDC registration.
|
|
|
|
**Scenarios**:
|
|
|
|
1. **User exists with matching `oidc_id`**: Allow (upsert)
|
|
2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired`
|
|
- The `LinkOidcAccountLive` will auto-link passwordless users without password prompt
|
|
- Password-protected users must verify their password
|
|
3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers)
|
|
4. **No user exists**: Allow (new user creation)
|
|
|
|
#### 4. Account Linking Action: `lib/accounts/user.ex`
|
|
|
|
```elixir
|
|
update :link_oidc_id do
|
|
description "Links an OIDC ID to an existing user after password verification"
|
|
accept []
|
|
argument :oidc_id, :string, allow_nil?: false
|
|
argument :oidc_user_info, :map, allow_nil?: false
|
|
# ... implementation
|
|
end
|
|
```
|
|
|
|
**Features**:
|
|
|
|
- Links `oidc_id` to existing user
|
|
- Updates email if it differs from OIDC provider
|
|
- Syncs email changes to linked member
|
|
|
|
#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex`
|
|
|
|
Refactored for better complexity and maintainability.
|
|
|
|
**Key improvements**:
|
|
|
|
- Reduced cyclomatic complexity from 11 to below 9
|
|
- Better separation of concerns with helper functions
|
|
- Comprehensive documentation
|
|
|
|
**Flow**:
|
|
|
|
1. Detects `PasswordVerificationRequired` error
|
|
2. Stores OIDC info in session
|
|
3. Redirects to account linking page
|
|
|
|
#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex`
|
|
|
|
Interactive UI for password verification and account linking.
|
|
|
|
**Flow**:
|
|
|
|
1. Retrieves OIDC info from session
|
|
2. **Auto-links passwordless users** immediately (no password prompt)
|
|
3. Displays password verification form for password-protected users
|
|
4. Verifies password using AshAuthentication
|
|
5. Links OIDC account on success
|
|
6. Redirects to complete OIDC login
|
|
7. **Logs all security-relevant events** (successful/failed linking attempts)
|
|
|
|
### Locale Persistence
|
|
|
|
**Problem**: Locale was lost on logout (session cleared).
|
|
|
|
**Solution**: Store locale in persistent cookie (1 year TTL) with security flags.
|
|
|
|
**Changes**:
|
|
|
|
- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags
|
|
- `lib/mv_web/router.ex`: Reads locale from cookie if session empty
|
|
|
|
**Security Features**:
|
|
- `http_only: true` - Cookie not accessible via JavaScript (XSS protection)
|
|
- `secure: true` - Cookie only transmitted over HTTPS in production
|
|
- `same_site: "Lax"` - CSRF protection
|
|
|
|
## Security Considerations
|
|
|
|
### 1. OIDC ID Matching
|
|
|
|
- **Before**: Matched by email (vulnerable to account takeover)
|
|
- **After**: Matched by `oidc_id` (secure)
|
|
|
|
### 2. Account Linking Flow
|
|
|
|
- Password verification required before linking (for password-protected users)
|
|
- Passwordless users are auto-linked immediately (secure, as they have no password)
|
|
- OIDC info stored in session (not in URL/query params)
|
|
- CSRF protection on all forms
|
|
- All linking attempts logged for audit trail
|
|
|
|
### 3. Email Updates
|
|
|
|
- Email updates from OIDC provider are applied during linking
|
|
- Email changes sync to linked member (if exists)
|
|
|
|
### 4. Error Handling
|
|
|
|
- Internal errors are logged but not exposed to users (prevents information disclosure)
|
|
- User-friendly error messages shown in UI
|
|
- Security-relevant events logged with appropriate levels:
|
|
- `Logger.info` for successful operations
|
|
- `Logger.warning` for failed authentication attempts
|
|
- `Logger.error` for system errors
|
|
|
|
## Usage Examples
|
|
|
|
### Scenario 1: New OIDC User
|
|
|
|
```elixir
|
|
# User signs in with OIDC for the first time
|
|
# → New user created with oidc_id
|
|
```
|
|
|
|
### Scenario 2: Existing OIDC User
|
|
|
|
```elixir
|
|
# User with oidc_id signs in via OIDC
|
|
# → Matched by oidc_id, email updated if changed
|
|
```
|
|
|
|
### Scenario 3: Password User + OIDC Login
|
|
|
|
```elixir
|
|
# User with password account tries OIDC login
|
|
# → PasswordVerificationRequired raised
|
|
# → Redirected to /auth/link-oidc-account
|
|
# → User enters password
|
|
# → Password verified and logged
|
|
# → oidc_id linked to account
|
|
# → Successful linking logged
|
|
# → Redirected to complete OIDC login
|
|
```
|
|
|
|
### Scenario 4: Passwordless User + OIDC Login
|
|
|
|
```elixir
|
|
# User without password (invited user) tries OIDC login
|
|
# → PasswordVerificationRequired raised
|
|
# → Redirected to /auth/link-oidc-account
|
|
# → System detects passwordless user
|
|
# → oidc_id automatically linked (no password prompt)
|
|
# → Auto-linking logged
|
|
# → Redirected to complete OIDC login
|
|
```
|
|
|
|
## API
|
|
|
|
### Custom Actions
|
|
|
|
#### `link_oidc_id`
|
|
|
|
Links an OIDC ID to existing user after password verification.
|
|
|
|
**Arguments**:
|
|
|
|
- `oidc_id` (required): OIDC sub/id from provider
|
|
- `oidc_user_info` (required): Full OIDC user info map
|
|
|
|
**Returns**: Updated user with linked `oidc_id`
|
|
|
|
**Side Effects**:
|
|
|
|
- Updates email if different from OIDC provider
|
|
- Syncs email to linked member (if exists)
|
|
|
|
## References
|
|
|
|
- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication)
|
|
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
|
|
- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)
|