feat(magiclink) prepare magic link support (login via email)
imortant on security side: magic link will be enabled only for users who don't have password nor OIDC
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Magic-link authentication tokens
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- One-shot opaque tokens issued in two situations:
|
||||
--
|
||||
-- 1. Invitation flow — an internal user shares a resource with someone by
|
||||
-- email; if the recipient has no account yet, OxiCloud creates an
|
||||
-- external user (`is_external = TRUE`, no password) and mints a token
|
||||
-- pointed at the target resource. Mail with `/magic/v1/{token}` is
|
||||
-- delivered; clicking lands on the resource directly.
|
||||
--
|
||||
-- 2. Login-via-email flow — a user with no other credential (typically a
|
||||
-- previously-invited external user) requests a fresh login link from
|
||||
-- `/login`. Token has NO resource target; redemption lands on
|
||||
-- `/shared-with-me`.
|
||||
--
|
||||
-- Tokens are 32 random bytes encoded as URL-safe base64 (43 chars). They
|
||||
-- are stored in plaintext (single-use; revealed in the URL anyway) and the
|
||||
-- table is indexed on `token` for O(1) redemption lookup.
|
||||
--
|
||||
-- Lifecycle states:
|
||||
-- pending → used (successful redemption; `used_at` stamped)
|
||||
-- pending → expired (background sweep when `expires_at < NOW()`)
|
||||
--
|
||||
-- The schema is intentionally close to `auth.device_codes` (initial_schema)
|
||||
-- so future maintenance lessons learnt on one transfer to the other.
|
||||
|
||||
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 = 'magic_link_status' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
CREATE TYPE auth.magic_link_status AS ENUM (
|
||||
'pending', -- Issued, not yet redeemed
|
||||
'used', -- Redeemed exactly once; cannot be reused
|
||||
'expired' -- TTL exceeded without redemption
|
||||
);
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.magic_link_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Plaintext base64url-encoded random bytes. The URL-as-credential model
|
||||
-- means this column is the secret; access is restricted by table-level
|
||||
-- permissions, not column-level hashing (matches device_codes).
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL
|
||||
REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
status auth.magic_link_status NOT NULL DEFAULT 'pending',
|
||||
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
-- Optional deep-link target. Both columns NULL → generic "login via
|
||||
-- email" flow, lands on /shared-with-me. Both NOT NULL → invitation
|
||||
-- flow, lands directly on /folders/{id} or /files/{id}. The XOR-on-
|
||||
-- NULL CHECK keeps the row consistent.
|
||||
resource_type TEXT
|
||||
CHECK (resource_type IS NULL OR resource_type IN ('file', 'folder')),
|
||||
resource_id UUID,
|
||||
CONSTRAINT magic_link_tokens_resource_pair
|
||||
CHECK ((resource_type IS NULL) = (resource_id IS NULL))
|
||||
);
|
||||
|
||||
-- Single-row lookup on every magic-link redemption.
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_token
|
||||
ON auth.magic_link_tokens (token);
|
||||
|
||||
-- Sweep of expired pending tokens (cleanup job).
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_expires_at
|
||||
ON auth.magic_link_tokens (expires_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- "List a user's outstanding tokens" (admin UI, or future
|
||||
-- on_external_user_credential_set invalidation flow).
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user_status
|
||||
ON auth.magic_link_tokens (user_id, status);
|
||||
|
||||
COMMENT ON TABLE auth.magic_link_tokens IS
|
||||
'One-shot opaque tokens for magic-link authentication (invitation + login-via-email flows). See migration file for the lifecycle and security model.';
|
||||
|
||||
COMMENT ON COLUMN auth.magic_link_tokens.token IS
|
||||
'URL-safe base64 of 32 random bytes (≈43 chars). Stored plaintext — the URL it sits in is the credential; column-level hashing would not change the threat model.';
|
||||
Reference in New Issue
Block a user