feat: implement OAuth 2.0 Device Authorization Grant (RFC 8628) for WebDAV/CalDAV/CardDAV

Adds full Device Authorization Grant flow so DAV clients (rclone, etc.)
can authenticate without browser-based OAuth redirects.

New files:
- Domain entity: DeviceCode with status lifecycle (pending/authorized/denied/expired)
- Port: DeviceCodeStoragePort trait (7 async methods)
- DTOs: request/response types for all device auth endpoints
- Repository: DeviceCodePgRepository (PostgreSQL implementation)
- Service: DeviceAuthService (initiate, verify, approve, deny, poll, cleanup)
- Handler: 6 HTTP endpoints (2 public + 4 protected)
- Static: device-verify.html verification page served at /device

Flow:
1. Client POST /api/auth/device/authorize → device_code + user_code
2. User opens /device?code=XXXX in browser, approves
3. Client polls POST /api/auth/device/token → receives JWT tokens
4. Client uses Bearer token with existing WebDAV/CalDAV/CardDAV middleware

Schema: auth.device_codes table + device_code_status enum added to schema.sql

Closes #152
This commit is contained in:
Dionisio
2026-03-01 11:54:43 +01:00
parent 2421724b80
commit 48d853360e
18 changed files with 1789 additions and 2 deletions
+48
View File
@@ -143,6 +143,54 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON auth.users(oidc_provider, oi
-- NOTE: No default users are created. The first user to register through
-- the admin setup wizard will become the administrator.
-- Device Authorization Grant (RFC 8628)
-- Used for WebDAV/CalDAV/CardDAV client authentication via the device flow.
DO $BODY$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'device_code_status' AND n.nspname = 'auth'
) THEN
CREATE TYPE auth.device_code_status AS ENUM (
'pending', -- Waiting for user to authorize
'authorized', -- User approved, tokens ready for polling client
'denied', -- User denied the request
'expired' -- TTL exceeded without user action
);
END IF;
END $BODY$;
CREATE TABLE IF NOT EXISTS auth.device_codes (
id VARCHAR(36) PRIMARY KEY,
device_code VARCHAR(128) UNIQUE NOT NULL,
user_code VARCHAR(16) UNIQUE NOT NULL,
client_name VARCHAR(255) NOT NULL DEFAULT 'Unknown Client',
scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav',
status auth.device_code_status NOT NULL DEFAULT 'pending',
user_id VARCHAR(36) REFERENCES auth.users(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
verification_uri TEXT NOT NULL,
verification_uri_complete TEXT,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
poll_interval_secs INTEGER NOT NULL DEFAULT 5,
last_poll_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
authorized_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_device_codes_device_code
ON auth.device_codes(device_code);
CREATE INDEX IF NOT EXISTS idx_device_codes_user_code
ON auth.device_codes(user_code) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_device_codes_expires_at
ON auth.device_codes(expires_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_device_codes_user_id
ON auth.device_codes(user_id) WHERE status = 'authorized';
COMMENT ON TABLE auth.device_codes IS 'OAuth 2.0 Device Authorization Grant (RFC 8628) codes for DAV client authentication';
-- ============================================================
-- 2. CALDAV SCHEMA (RFC 4791)
-- ============================================================