-- ============================================================================ -- MagicAuth Postgres Installation Script -- ============================================================================ -- -- This script sets up a tenant's Postgres database to validate JWTs issued -- by the MagicAuth service using the tenant's public key (RS256). -- -- Prerequisites: -- - plpython3u extension (for JWT verification) -- - PyJWT Python package installed on the PostgreSQL server: -- pip install pyjwt cryptography -- -- Usage: -- psql -d your_database -f install.sql -- -- After running this script: -- 1. Insert your tenant's public key into auth.trusted_tenants -- 2. Call auth.set_jwt() at the start of each session with the JWT -- 3. Use auth.email(), auth.role(), auth.tenant_id() in RLS -- -- ============================================================================ -- Create auth schema CREATE SCHEMA IF NOT EXISTS auth; -- Enable required extensions CREATE EXTENSION IF NOT EXISTS plpython3u; -- for RS256 JWT verification -- ============================================================================ -- TABLES -- ============================================================================ -- Trusted tenants table: stores public keys for JWT verification -- Each tenant registers their public key here (obtained from /v1/register-tenant) CREATE TABLE IF NOT EXISTS auth.trusted_tenants ( tenant_id TEXT PRIMARY KEY, public_key_pem TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); -- App users table: stores user information and permissions -- Tenants manage this table to define which emails have access CREATE TABLE IF NOT EXISTS app_users ( email TEXT PRIMARY KEY, role TEXT NOT NULL DEFAULT 'user', is_active BOOLEAN DEFAULT true ); -- ============================================================================ -- JWT HELPER FUNCTIONS -- ============================================================================ -- Decode JWT payload without verification -- Used only as input to verify_jwt to extract tenant_id for public key lookup -- WARNING: Do not trust this output for authorization - always use verify_jwt CREATE OR REPLACE FUNCTION auth.decode_jwt_payload_unsafe(token TEXT) RETURNS JSONB LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT convert_from( decode( rpad( translate((string_to_array(token, '.'))[2], '-_', '+/'), 4 * ceil(length((string_to_array(token, '.'))[2]) / 4.0)::int, '=' ), 'base64' ), 'UTF8' )::jsonb; $$; COMMENT ON FUNCTION auth.decode_jwt_payload_unsafe(TEXT) IS 'Decodes JWT payload WITHOUT signature verification. Never use for authorization.'; -- ============================================================================ -- JWT VERIFICATION (RS256 via PL/Python) -- ============================================================================ -- Verify JWT signature and expiration using the tenants public key -- Returns the verified claims if valid, raises exception if invalid -- Uses PyJWT library for RS256 verification CREATE OR REPLACE FUNCTION auth.verify_jwt(token TEXT) RETURNS JSONB LANGUAGE plpython3u SECURITY DEFINER AS $$ import json # Validate input if not token: plpy.error('JWT token is required') # First, decode without verification to get tenant_id try: import jwt unverified = jwt.decode(token, options={"verify_signature": False}) except Exception as e: plpy.error(f'Malformed JWT: {str(e)}') tenant_id = unverified.get('tenant_id') if not tenant_id: plpy.error('JWT missing tenant_id claim') # Look up tenant's public key from trusted_tenants (use prepared statement for safety) plan = plpy.prepare("SELECT public_key_pem FROM auth.trusted_tenants WHERE tenant_id = $1", ["text"]) result = plpy.execute(plan, [tenant_id]) if len(result) == 0: plpy.error(f'Unknown tenant: {tenant_id}') public_key = result[0]['public_key_pem'] # Verify the JWT with the public key try: # PyJWT automatically verifies signature and expiration verified = jwt.decode( token, public_key, algorithms=['RS256'], options={ "require": ["exp", "iat", "tenant_id", "email"], "verify_exp": True, "verify_iat": True } ) return json.dumps(verified) except jwt.ExpiredSignatureError: plpy.error('JWT has expired') except jwt.InvalidSignatureError: plpy.error('Invalid JWT signature') except jwt.InvalidTokenError as e: plpy.error(f'Invalid JWT: {str(e)}') $$; COMMENT ON FUNCTION auth.verify_jwt(TEXT) IS 'Verifies JWT signature using tenant public key and checks expiration. Returns claims if valid.'; -- ============================================================================ -- SESSION CLAIMS MANAGEMENT -- ============================================================================ -- Set session claims from a verified JWT -- This is the main entry point - call this at the start of each request CREATE OR REPLACE FUNCTION auth.set_jwt(token TEXT) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE claims JSONB; u RECORD; BEGIN -- Verify JWT and get claims claims := auth.verify_jwt(token); -- Set base claims from JWT PERFORM set_config('request.jwt.claim.email', claims->>'email', true); PERFORM set_config('request.jwt.claim.tenant_id', claims->>'tenant_id', true); PERFORM set_config('request.jwt.claim.sub', claims->>'sub', true); PERFORM set_config('request.jwt.claim.iss', claims->>'iss', true); PERFORM set_config('request.jwt.claim.iat', claims->>'iat', true); PERFORM set_config('request.jwt.claim.exp', claims->>'exp', true); -- Look up user in app_users to get role SELECT * INTO u FROM app_users WHERE email_address = claims->>'email' AND is_active = true; IF FOUND THEN PERFORM set_config('request.jwt.claim.role', u.role, true); PERFORM set_config('request.jwt.claim.is_registered', 'true', true); ELSE -- User authenticated via email but not in app_users -- Set flag so application can handle registration PERFORM set_config('request.jwt.claim.role', '', true); PERFORM set_config('request.jwt.claim.is_registered', 'false', true); END IF; END; $$; COMMENT ON FUNCTION auth.set_jwt(TEXT) IS 'Validates JWT and sets session claims for use in RLS policies. Call at start of each request.'; -- Clear session claims (call at end of request or on error) CREATE OR REPLACE FUNCTION auth.clear_jwt() RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN PERFORM set_config('request.jwt.claim.email', '', true); PERFORM set_config('request.jwt.claim.tenant_id', '', true); PERFORM set_config('request.jwt.claim.role', '', true); PERFORM set_config('request.jwt.claim.sub', '', true); PERFORM set_config('request.jwt.claim.iss', '', true); PERFORM set_config('request.jwt.claim.iat', '', true); PERFORM set_config('request.jwt.claim.exp', '', true); PERFORM set_config('request.jwt.claim.is_registered', '', true); END; $$; COMMENT ON FUNCTION auth.clear_jwt() IS 'Clears all JWT session claims. Call at end of request for connection pooling.'; -- ============================================================================ -- CLAIM GETTER FUNCTIONS -- ============================================================================ -- Use these in RLS policies and application queries CREATE OR REPLACE FUNCTION auth.email() RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('request.jwt.claim.email', true), ''); $$; CREATE OR REPLACE FUNCTION auth.role() RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('request.jwt.claim.role', true), ''); $$; CREATE OR REPLACE FUNCTION auth.tenant_id() RETURNS TEXT LANGUAGE sql STABLE AS $$ SELECT nullif(current_setting('request.jwt.claim.tenant_id', true), ''); $$; CREATE OR REPLACE FUNCTION auth.is_registered() RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT current_setting('request.jwt.claim.is_registered', true) = 'true'; $$; -- ============================================================================ -- CONVENIENCE FUNCTIONS -- ============================================================================ -- Check if user has a specific role CREATE OR REPLACE FUNCTION auth.has_role(required_role TEXT) RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT auth.role() = required_role; $$; -- Check if user has any of the specified roles CREATE OR REPLACE FUNCTION auth.has_any_role(VARIADIC roles TEXT[]) RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT auth.role() = ANY(roles); $$; -- Get the authenticated users record from app_users CREATE OR REPLACE FUNCTION auth.current_user_record() RETURNS app_users LANGUAGE sql STABLE SECURITY DEFINER AS $$ SELECT * FROM app_users WHERE email_address = auth.email() LIMIT 1; $$; -- ============================================================================ -- COMMENTS -- ============================================================================ COMMENT ON SCHEMA auth IS 'MagicAuth JWT validation and session management'; COMMENT ON TABLE auth.trusted_tenants IS 'Stores trusted tenant public keys for RS256 JWT verification'; COMMENT ON TABLE app_users IS 'Application users with roles and permissions'; COMMENT ON FUNCTION auth.email() IS 'Returns authenticated user email from JWT'; COMMENT ON FUNCTION auth.role() IS 'Returns authenticated user role (NULL if not registered)'; COMMENT ON FUNCTION auth.tenant_id() IS 'Returns tenant_id from JWT'; COMMENT ON FUNCTION auth.is_registered() IS 'Returns true if authenticated user exists in app_users'; -- ============================================================================ -- EXAMPLE: REGISTERING A TRUSTED TENANT -- ============================================================================ -- -- After obtaining the public key from POST /v1/register-tenant: -- -- INSERT INTO auth.trusted_tenants (tenant_id, public_key_pem) -- VALUES ( -- '550e8400-e29b-41d4-a716-446655440000', -- '-----BEGIN PUBLIC KEY----- -- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -- -----END PUBLIC KEY-----' -- ); -- -- ============================================================================ -- EXAMPLE: USING IN RLS POLICIES -- ============================================================================ -- -- -- Users can only see their own data -- CREATE POLICY user_isolation ON my_table -- FOR ALL -- USING (email_address = auth.email()); -- -- ============================================================================ -- EXAMPLE: CLIENT USAGE -- ============================================================================ -- -- BEGIN; -- SELECT auth.set_jwt('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'); -- -- -- Now RLS policies can use auth.* functions -- SELECT * FROM my_table; -- -- -- Optionally check if user is registered -- SELECT auth.is_registered(); -- returns false for new users -- -- -- Register new user if needed -- INSERT INTO app_users (email, role) -- VALUES (auth.email(), 'user') -- ON CONFLICT (email) DO NOTHING; -- -- COMMIT; -- -- ============================================================================