diff --git a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py index e38a13b..12f9e3e 100644 --- a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py +++ b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py @@ -112,7 +112,7 @@ def upgrade() -> None: THEN 'contact.map_or_create' ELSE 'contact.update' END; END IF; v_dedup := v_task_type || ':' || v_entity_id::text || ':' || - encode(digest(row_to_json(NEW)::text, 'sha256'), 'hex'); + encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex'); INSERT INTO han_app.sync_queue (id, task_type, entity_type, entity_id, dedup_key, payload_json, status, attempt_count, next_attempt_at, created_at, updated_at) diff --git a/codebase/backend/api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py b/codebase/backend/api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py new file mode 100644 index 0000000..f46b56d --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py @@ -0,0 +1,62 @@ +"""Qualify pgcrypto digest in the contact sync trigger. + +Revision ID: 0002_pgcrypto_digest +Revises: 0001_initial +Create Date: 2026-07-16 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0002_pgcrypto_digest" +down_revision: str | None = "0001_initial" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync() + RETURNS trigger + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = han_app, pg_temp + AS $$ + DECLARE + v_entity_id uuid; + v_task_type text; + v_dedup text; + BEGIN + IF current_setting('han.sync_suppress', true) = 'true' THEN + RETURN NEW; + END IF; + IF TG_TABLE_NAME = 'user_identities' THEN + v_entity_id := NEW.id; + v_task_type := CASE WHEN TG_OP = 'INSERT' + THEN 'contact.map_or_create' ELSE 'contact.update' END; + ELSE + v_entity_id := NEW.user_id; + v_task_type := CASE WHEN TG_OP = 'INSERT' + THEN 'contact.map_or_create' ELSE 'contact.update' END; + END IF; + v_dedup := v_task_type || ':' || v_entity_id::text || ':' || + encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex'); + INSERT INTO han_app.sync_queue + (id, task_type, entity_type, entity_id, dedup_key, payload_json, + status, attempt_count, next_attempt_at, created_at, updated_at) + VALUES + (gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup, + jsonb_build_object('entity_id', v_entity_id), 'pending', 0, + now(), now(), now()) + ON CONFLICT (dedup_key) DO NOTHING; + RETURN NEW; + END; + $$ + """ + ) + + +def downgrade() -> None: + raise RuntimeError("Contact sync trigger migration is forward-only") diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index b19b8e4..25dcb50 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -357,7 +357,7 @@ async def ready(request: Request, db: Session): try: await db.execute(text("SELECT 1")) revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1")) - if revision != "0001_initial": + if revision != "0002_pgcrypto_digest": raise RuntimeError("unexpected database revision") await load_settings(db) components["postgres"] = "ok" diff --git a/codebase/backend/frontend-test-site/app/auth/callback.tsx b/codebase/backend/frontend-test-site/app/auth/callback.tsx index 9b5e2ef..32133c3 100644 --- a/codebase/backend/frontend-test-site/app/auth/callback.tsx +++ b/codebase/backend/frontend-test-site/app/auth/callback.tsx @@ -1,5 +1,5 @@ import { useLocalSearchParams } from "expo-router"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Text, View } from "react-native"; import { useApp } from "../../src/app-context"; import type { Consents } from "../../src/types"; @@ -9,6 +9,7 @@ export default function AuthCallbackScreen() { const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>(); const app = useApp(); const [error, setError] = useState(); + const completionStarted = useRef(false); const retry = () => { if (!params.code || !params.state || typeof window === "undefined") return; const raw = window.sessionStorage.getItem("han.pending-consents"); @@ -17,11 +18,14 @@ export default function AuthCallbackScreen() { void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError); }; useEffect(() => { + if (completionStarted.current) return; if (params.error) { + completionStarted.current = true; setError(new Error("Авторизация отменена или отклонена.")); return; } if (!params.code || !params.state) return; + completionStarted.current = true; const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null; if (!raw) { setError(new Error("Не найдены локально принятые согласия. Начните вход заново.")); diff --git a/codebase/backend/frontend-test-site/src/auth.ts b/codebase/backend/frontend-test-site/src/auth.ts index da3502a..61b3352 100644 --- a/codebase/backend/frontend-test-site/src/auth.ts +++ b/codebase/backend/frontend-test-site/src/auth.ts @@ -40,7 +40,7 @@ const secureStore = { }; const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", ""); -const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "oauth/callback" }); +const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" }); const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`; export function configureAuthFailure(callback: () => void) { @@ -95,6 +95,10 @@ export async function beginAuthorization() { state, nonce, })}`; + if (Platform.OS === "web" && typeof window !== "undefined") { + window.location.assign(url); + return new Promise(() => undefined); + } const result = await WebBrowser.openAuthSessionAsync(url, redirectUri); if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" }; const callback = new URL(result.url); diff --git a/codebase/backend/keycloak/realm/han-chat-realm.json b/codebase/backend/keycloak/realm/han-chat-realm.json index 644b998..a00200f 100644 --- a/codebase/backend/keycloak/realm/han-chat-realm.json +++ b/codebase/backend/keycloak/realm/han-chat-realm.json @@ -60,7 +60,7 @@ "frontchannelLogout": true, "fullScopeAllowed": false, "redirectUris": [ - "https://chat.han0107.ru/oauth/callback", + "https://chat.han0107.ru/auth/callback", "han-chat://auth/callback" ], "webOrigins": [ @@ -74,6 +74,20 @@ "use.refresh.tokens": "true", "client.use.lightweight.access.token.enabled": "false" }, + "protocolMappers": [ + { + "name": "subject", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + } + ], "defaultClientScopes": ["phone", "han-chat-api"], "optionalClientScopes": ["offline_access"] } diff --git a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java index 1dcaee6..f1b93e9 100644 --- a/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java +++ b/codebase/backend/keycloak/src/test/java/ru/han/chat/keycloak/RealmContractTest.java @@ -23,6 +23,7 @@ class RealmContractTest { assertTrue(realm.contains("\"included.client.audience\": \"han-chat-api\"")); assertTrue(realm.contains("\"claim.name\": \"phone_number\"")); assertTrue(realm.contains("\"claim.name\": \"phone_number_verified\"")); + assertTrue(realm.contains("\"protocolMapper\": \"oidc-sub-mapper\"")); assertTrue(realm.contains("\"revokeRefreshToken\": true")); assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0")); assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]")); diff --git a/codebase/backend/nginx/templates/site-tls.conf.template b/codebase/backend/nginx/templates/site-tls.conf.template index 6fe513b..4c298d3 100644 --- a/codebase/backend/nginx/templates/site-tls.conf.template +++ b/codebase/backend/nginx/templates/site-tls.conf.template @@ -97,6 +97,12 @@ server { proxy_pass http://api_backend; } + location = /auth/callback { + root /usr/share/nginx/html; + try_files /index.html =404; + add_header Cache-Control "no-cache"; + include /etc/nginx/generated/security-headers.conf; + } location ^~ /auth/resources/ { include /etc/nginx/snippets/proxy-keycloak.conf; proxy_pass http://keycloak_upstream; diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index 29cdd75..95868d1 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -90,6 +90,7 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertNotIn("proxy_read_timeout", proxy_common) self.assertNotIn("proxy_send_timeout", proxy_common) self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site) + self.assertIn("location = /auth/callback", site) self.assertIn("location ^~ /auth/resources/", site) self.assertIn("protocol/openid-connect/3p-cookies/", site) self.assertNotIn("security-headers.conf", proxy_keycloak) @@ -151,9 +152,17 @@ class InfrastructureConfigTests(unittest.TestCase): def test_expo_public_environment_uses_static_property_access(self) -> None: config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8") + auth = (ROOT / "frontend-test-site/src/auth.ts").read_text(encoding="utf-8") + callback = (ROOT / "frontend-test-site/app/auth/callback.tsx").read_text( + encoding="utf-8" + ) self.assertNotIn("process.env[name]", config) self.assertIn("process.env.EXPO_PUBLIC_API_BASE_URL", config) self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config) + self.assertIn('path: "auth/callback"', auth) + self.assertIn('window.location.assign(url)', auth) + self.assertIn("completionStarted.current", callback) + self.assertTrue((ROOT / "frontend-test-site/app/auth/callback.tsx").is_file()) def test_alembic_escapes_percent_encoded_dsn_options(self) -> None: for relative_path in ( @@ -165,6 +174,19 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn('.replace("%", "%%")', env_script, relative_path) self.assertIn("create_postgres_engine", env_script, relative_path) + def test_contact_sync_qualifies_pgcrypto_digest(self) -> None: + initial = ( + ROOT / "api-backend/alembic/versions/0001_initial_han_app.py" + ).read_text(encoding="utf-8") + fix = ( + ROOT / "api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py" + ).read_text(encoding="utf-8") + main = (ROOT / "api-backend/app/main.py").read_text(encoding="utf-8") + self.assertIn("public.digest(", initial) + self.assertIn("public.digest(", fix) + self.assertIn('down_revision: str | None = "0001_initial"', fix) + self.assertIn('revision != "0002_pgcrypto_digest"', main) + def test_keycloak_management_health_and_bridge_environment(self) -> None: standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8") self.assertIn("GET /auth/health/ready", standalone)