Skip to content

Commit 9092418

Browse files
phantomiiakremenetsky
authored andcommitted
IAM IDP: support multiple callback URI formats
Replace single callback_uri string with callback kind model supporting three formats: single URI, URI list, and regexp pattern. Store redirect_uri in authorization info for validation during token exchange.
1 parent ecfeca2 commit 9092418

3 files changed

Lines changed: 223 additions & 6 deletions

File tree

genesis_core/tests/functional/restapi/iam/test_idp.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,18 @@ def _build_create_payload(self, iam_client_uuid: str):
3838
return {
3939
"name": "test-idp",
4040
"description": "test-idp-desc",
41-
"callback_uri": "http://example.test/callback",
41+
"callback": {
42+
"kind": "callback_uri",
43+
"callback": "http://example.test/callback",
44+
},
4245
"iam_client": self._iam_client_uri(iam_client_uuid),
4346
}
4447

48+
def _build_create_payload_with_callback(self, iam_client_uuid: str, callback: dict):
49+
payload = self._build_create_payload(iam_client_uuid)
50+
payload["callback"] = callback
51+
return payload
52+
4553
def _create_idp(self, client, iam_client_uuid: str):
4654
url = self._collection_url(client)
4755
response = client.post(url, json=self._build_create_payload(iam_client_uuid))
@@ -83,6 +91,68 @@ def test_create_idp_with_permission_success(self, user_api_client, auth_test1_us
8391

8492
assert idp["name"] == "test-idp"
8593

94+
def test_create_idp_with_callback_uri_list_success(
95+
self, user_api_client, auth_test1_user
96+
):
97+
client = user_api_client(
98+
auth_test1_user,
99+
permissions=[
100+
iam_c.PERMISSION_IDP_CREATE,
101+
],
102+
)
103+
url = self._collection_url(client)
104+
105+
response = client.post(
106+
url,
107+
json=self._build_create_payload_with_callback(
108+
iam_client_uuid=auth_test1_user.client_uuid,
109+
callback={
110+
"kind": "callback_uri_list",
111+
"callbacks": [
112+
"http://example.test/callback",
113+
"http://example.test/callback-2",
114+
],
115+
},
116+
),
117+
)
118+
119+
assert response.status_code == 201
120+
idp = response.json()
121+
assert idp["callback"]["kind"] == "callback_uri_list"
122+
assert idp["callback"]["callbacks"] == [
123+
"http://example.test/callback",
124+
"http://example.test/callback-2",
125+
]
126+
127+
def test_create_idp_with_callback_regexp_success(
128+
self, user_api_client, auth_test1_user
129+
):
130+
client = user_api_client(
131+
auth_test1_user,
132+
permissions=[
133+
iam_c.PERMISSION_IDP_CREATE,
134+
],
135+
)
136+
url = self._collection_url(client)
137+
138+
response = client.post(
139+
url,
140+
json=self._build_create_payload_with_callback(
141+
iam_client_uuid=auth_test1_user.client_uuid,
142+
callback={
143+
"kind": "callback_regexp",
144+
"pattern": r"https://example\.test/callback(/.*)?",
145+
},
146+
),
147+
)
148+
149+
assert response.status_code == 201
150+
idp = response.json()
151+
assert idp["callback"]["kind"] == "callback_regexp"
152+
assert idp["callback"]["pattern"] == (
153+
r"https://example\.test/callback(/.*)?"
154+
)
155+
86156
def test_list_idp_no_permission_fails(self, user_api_client, auth_test1_user):
87157
client = user_api_client(auth_test1_user)
88158
url = self._collection_url(client)

genesis_core/user_api/iam/dm/models.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import base64
1818
import datetime
1919
import enum
20+
import re
2021
import hashlib
2122
import secrets
2223
import typing as tp
@@ -168,6 +169,48 @@ def list_response_types(cls):
168169
return [cls.CODE.value]
169170

170171

172+
class IdpCallbackBase(ra_types_dynamic.AbstractKindModel, models.SimpleViewMixin):
173+
def check(self, redirect_uri: str) -> bool:
174+
raise NotImplementedError()
175+
176+
177+
class IdpCallbackUriKind(IdpCallbackBase):
178+
KIND = "callback_uri"
179+
180+
callback = properties.property(
181+
ra_types.String(max_length=256),
182+
required=True,
183+
)
184+
185+
def check(self, redirect_uri: str) -> bool:
186+
return self.callback == redirect_uri
187+
188+
189+
class IdpCallbackUriListKind(IdpCallbackBase):
190+
KIND = "callback_uri_list"
191+
192+
callbacks = properties.property(
193+
ra_types.TypedList(ra_types.String(max_length=256)),
194+
required=True,
195+
default=list,
196+
)
197+
198+
def check(self, redirect_uri: str) -> bool:
199+
return redirect_uri in self.callbacks
200+
201+
202+
class IdpCallbackRegexpKind(IdpCallbackBase):
203+
KIND = "callback_regexp"
204+
205+
pattern = properties.property(
206+
ra_types.String(max_length=256),
207+
required=True,
208+
)
209+
210+
def check(self, redirect_uri: str) -> bool:
211+
return re.fullmatch(self.pattern, redirect_uri) is not None
212+
213+
171214
class AbstractUserSource(ra_types_dynamic.AbstractKindModel):
172215
def process_secret(self, user, secret):
173216
raise NotImplementedError()
@@ -1091,7 +1134,7 @@ def get_token_by_authorization_code(self, code, redirect_uri):
10911134
for auth_info in IdpAuthorizationInfo.objects.get_all(
10921135
filters={"code": ra_filters.EQ(code)}
10931136
):
1094-
if auth_info.idp.callback_uri == redirect_uri:
1137+
if auth_info.redirect_uri == redirect_uri:
10951138
auth_info.delete()
10961139
return auth_info.token
10971140

@@ -1495,8 +1538,12 @@ class Idp(
14951538
ra_types.String(max_length=64),
14961539
default="openid",
14971540
)
1498-
callback_uri = properties.property(
1499-
ra_types.String(max_length=256),
1541+
callback = properties.property(
1542+
KindModelSelectorType(
1543+
ra_types_dynamic.KindModelType(IdpCallbackUriKind),
1544+
ra_types_dynamic.KindModelType(IdpCallbackUriListKind),
1545+
ra_types_dynamic.KindModelType(IdpCallbackRegexpKind),
1546+
),
15001547
required=True,
15011548
)
15021549
nonce_required = properties.property(
@@ -1560,7 +1607,7 @@ def authorize(
15601607
):
15611608
if self.client_id != client_id:
15621609
raise iam_exceptions.InvalidClientId(client_id=client_id)
1563-
if self.callback_uri != redirect_uri:
1610+
if not self.callback.check(redirect_uri):
15641611
raise iam_exceptions.InvalidRedirectUri(redirect_uri=redirect_uri)
15651612
if self.nonce_required and not nonce:
15661613
raise iam_exceptions.InvalidNonce(nonce=nonce)
@@ -1574,6 +1621,7 @@ def authorize(
15741621
response_type=response_type,
15751622
nonce=nonce,
15761623
scope=scope,
1624+
redirect_uri=redirect_uri,
15771625
)
15781626

15791627
auth_info.insert()
@@ -1586,7 +1634,11 @@ def authorize(
15861634
)
15871635

15881636
def construct_callback_uri(self, auth_info):
1589-
return self.callback_uri + f"?code={auth_info.code}&state={auth_info.state}"
1637+
if not auth_info.redirect_uri:
1638+
raise iam_exceptions.InvalidRedirectUri(redirect_uri="")
1639+
return (
1640+
auth_info.redirect_uri + f"?code={auth_info.code}&state={auth_info.state}"
1641+
)
15901642

15911643

15921644
class IdpAuthorizationInfo(
@@ -1612,6 +1664,11 @@ class IdpAuthorizationInfo(
16121664
ra_types.String(max_length=256),
16131665
required=True,
16141666
)
1667+
redirect_uri = properties.property(
1668+
ra_types.String(max_length=256),
1669+
required=True,
1670+
default="",
1671+
)
16151672
scope = properties.property(
16161673
ra_types.String(max_length=256),
16171674
required=True,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright 2026 Genesis Corporation
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
from restalchemy.storage.sql import migrations
18+
19+
20+
class MigrationStep(migrations.AbstractMigrationStep):
21+
def __init__(self):
22+
self._depends = [
23+
"0053-lb-node-secret-set-permissions-11c9a8.py",
24+
]
25+
26+
@property
27+
def migration_id(self):
28+
return "3a6c1b7a-20a0-4d3b-8896-d183f5eb2e6b"
29+
30+
@property
31+
def is_manual(self):
32+
return False
33+
34+
def upgrade(self, session):
35+
expressions = [
36+
"""
37+
ALTER TABLE "iam_idp"
38+
ADD COLUMN IF NOT EXISTS "callback" JSONB
39+
NOT NULL DEFAULT '{"kind": "callback_uri", "callback": ""}'::jsonb;
40+
""",
41+
"""
42+
UPDATE "iam_idp"
43+
SET "callback" = jsonb_build_object(
44+
'kind', 'callback_uri',
45+
'callback', COALESCE("callback_uri", '')
46+
);
47+
""",
48+
"""
49+
ALTER TABLE "iam_idp"
50+
DROP COLUMN IF EXISTS "callback_uri";
51+
""",
52+
"""
53+
DELETE FROM "iam_idp_authorization_info";
54+
""",
55+
"""
56+
ALTER TABLE "iam_idp_authorization_info"
57+
ADD COLUMN IF NOT EXISTS "redirect_uri" VARCHAR(256)
58+
NOT NULL DEFAULT '';
59+
""",
60+
]
61+
62+
for expression in expressions:
63+
session.execute(expression)
64+
65+
def downgrade(self, session):
66+
expressions = [
67+
"""
68+
ALTER TABLE "iam_idp"
69+
ADD COLUMN IF NOT EXISTS "callback_uri" VARCHAR(256)
70+
NOT NULL DEFAULT '';
71+
""",
72+
"""
73+
UPDATE "iam_idp"
74+
SET "callback_uri" = COALESCE("callback"->>'callback', '');
75+
""",
76+
"""
77+
ALTER TABLE "iam_idp"
78+
DROP COLUMN IF EXISTS "callback";
79+
""",
80+
"""
81+
ALTER TABLE "iam_idp_authorization_info"
82+
DROP COLUMN IF EXISTS "redirect_uri";
83+
""",
84+
]
85+
86+
for expression in expressions:
87+
session.execute(expression)
88+
89+
90+
migration_step = MigrationStep()

0 commit comments

Comments
 (0)