현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -70,14 +70,20 @@ def _fixture_saml_response(
|
|||
email: str = "learner@hs.ac.kr",
|
||||
display_name: str = "SAML Learner",
|
||||
role: str = "learner",
|
||||
cohort: str | None = None,
|
||||
) -> str:
|
||||
cohort_attr = (
|
||||
f'\n <saml:Attribute Name="cohort"><saml:AttributeValue>{cohort}</saml:AttributeValue></saml:Attribute>'
|
||||
if cohort
|
||||
else ""
|
||||
)
|
||||
xml = f"""<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
||||
<saml:Assertion>
|
||||
<saml:Subject><saml:NameID>{email}</saml:NameID></saml:Subject>
|
||||
<saml:AttributeStatement>
|
||||
<saml:Attribute Name="email"><saml:AttributeValue>{email}</saml:AttributeValue></saml:Attribute>
|
||||
<saml:Attribute Name="displayName"><saml:AttributeValue>{display_name}</saml:AttributeValue></saml:Attribute>
|
||||
<saml:Attribute Name="role"><saml:AttributeValue>{role}</saml:AttributeValue></saml:Attribute>
|
||||
<saml:Attribute Name="role"><saml:AttributeValue>{role}</saml:AttributeValue></saml:Attribute>{cohort_attr}
|
||||
</saml:AttributeStatement>
|
||||
</saml:Assertion>
|
||||
</samlp:Response>"""
|
||||
|
|
@ -157,6 +163,25 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertTrue(config.dev_login_enabled)
|
||||
|
||||
async def test_auth_config_keeps_dev_login_closed_for_public_origin(self) -> None:
|
||||
request = _request(
|
||||
[
|
||||
(b"host", b"127.0.0.1:8000"),
|
||||
(b"origin", b"https://vignette.chanpaca.net"),
|
||||
(b"x-forwarded-host", b"api-vignette.chanpaca.net"),
|
||||
(b"x-forwarded-proto", b"https"),
|
||||
]
|
||||
)
|
||||
|
||||
with patched_settings(
|
||||
environment="dev",
|
||||
auth_dev_login_enabled=True,
|
||||
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
|
||||
):
|
||||
config = await auth_routes.auth_config(request)
|
||||
|
||||
self.assertFalse(config.dev_login_enabled)
|
||||
|
||||
async def test_frontend_origin_map_routes_vnet_api_callbacks_to_vnet_frontend(self) -> None:
|
||||
request = _request([(b"host", b"api-vnet.18ka.net")])
|
||||
|
||||
|
|
@ -233,6 +258,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
display_name="SAML Learner",
|
||||
role="teacher",
|
||||
cohort_ids=[],
|
||||
external_id="saml:learner@hs.ac.kr",
|
||||
)
|
||||
cookie_blob = "\n".join(
|
||||
value.decode("latin1")
|
||||
|
|
@ -240,6 +266,54 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
if name.lower() == b"set-cookie"
|
||||
)
|
||||
self.assertIn("__Host-vignette_sid=opaque-session", cookie_blob)
|
||||
|
||||
async def test_saml_acs_maps_cohort_claim_into_session(self) -> None:
|
||||
relay_state = "relay-state"
|
||||
auth_routes._saml_states[relay_state] = auth_routes.SamlState(
|
||||
request_id="_request",
|
||||
next_path="/teach",
|
||||
created_at=1_800_000_000.0,
|
||||
)
|
||||
request = _form_request(
|
||||
"/auth/saml/acs",
|
||||
{
|
||||
"RelayState": relay_state,
|
||||
"SAMLResponse": _fixture_saml_response(
|
||||
email="teacher@hs.ac.kr",
|
||||
display_name="Teacher",
|
||||
role="teacher",
|
||||
cohort="counseling-2026-a, lab-b",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
create_session_mock = AsyncMock(return_value=("opaque-session", object()))
|
||||
with (
|
||||
patched_settings(
|
||||
auth_saml_enabled=True,
|
||||
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
|
||||
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
|
||||
saml_x509_cert_fingerprint="",
|
||||
frontend_base_url="https://vignette.test",
|
||||
environment="dev",
|
||||
),
|
||||
patch.object(auth_routes, "create_session", create_session_mock),
|
||||
):
|
||||
response = await auth_routes.saml_acs(request)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
create_session_mock.assert_awaited_once_with(
|
||||
email="teacher@hs.ac.kr",
|
||||
display_name="Teacher",
|
||||
role="teacher",
|
||||
cohort_ids=["counseling-2026-a", "lab-b"],
|
||||
external_id="saml:teacher@hs.ac.kr",
|
||||
)
|
||||
cookie_blob = "\n".join(
|
||||
value.decode("latin1")
|
||||
for name, value in response.raw_headers
|
||||
if name.lower() == b"set-cookie"
|
||||
)
|
||||
self.assertIn("HttpOnly", cookie_blob)
|
||||
self.assertIn("Secure", cookie_blob)
|
||||
self.assertNotIn("SAMLResponse", cookie_blob)
|
||||
|
|
@ -330,6 +404,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
async def test_google_login_uses_pkce_state_without_exposing_secret(self) -> None:
|
||||
with patched_settings(
|
||||
environment="prod",
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
|
|
@ -353,6 +428,41 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
query["code_challenge"],
|
||||
[auth_routes._pkce_challenge(stored.code_verifier)],
|
||||
)
|
||||
cookie_blob = "\n".join(
|
||||
value.decode("latin1")
|
||||
for name, value in response.raw_headers
|
||||
if name.lower() == b"set-cookie"
|
||||
)
|
||||
self.assertIn("__Host-vignette_oauth_state=", cookie_blob)
|
||||
self.assertIn("HttpOnly", cookie_blob)
|
||||
self.assertIn("Secure", cookie_blob)
|
||||
|
||||
async def test_dev_google_login_rejects_public_callback_redirect(self) -> None:
|
||||
request = _request(
|
||||
[
|
||||
(b"host", b"127.0.0.1:8010"),
|
||||
(b"x-forwarded-host", b"alpaca-home.taile93291.ts.net"),
|
||||
(b"x-forwarded-proto", b"https"),
|
||||
]
|
||||
)
|
||||
|
||||
with patched_settings(
|
||||
environment="dev",
|
||||
auth_dev_login_enabled=True,
|
||||
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.chanpaca.net/auth/callback",
|
||||
frontend_base_url="https://vignette.chanpaca.net",
|
||||
cors_origins=["https://vignette.chanpaca.net"],
|
||||
):
|
||||
response = await auth_routes.login(request, provider="google", next="/learn")
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
location = response.headers["location"]
|
||||
self.assertIn("https://alpaca-home.taile93291.ts.net/login", location)
|
||||
self.assertIn("oauth=local_oauth_unavailable", location)
|
||||
self.assertFalse(auth_routes._oauth_states)
|
||||
|
||||
async def test_google_callback_sets_opaque_cookie_without_browser_tokens(self) -> None:
|
||||
state = "state-token"
|
||||
|
|
@ -401,6 +511,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
},
|
||||
)
|
||||
|
||||
create_session_mock = AsyncMock(return_value=("opaque-session", object()))
|
||||
with (
|
||||
patched_settings(
|
||||
oauth_google_client_id="google-client",
|
||||
|
|
@ -408,14 +519,28 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
frontend_base_url="https://vignette.test",
|
||||
environment="prod",
|
||||
auth_domain_cohort_map={"hs.ac.kr": "hanshin-2026"},
|
||||
auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"},
|
||||
),
|
||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||
patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))),
|
||||
patch.object(auth_routes, "create_session", create_session_mock),
|
||||
):
|
||||
response = await auth_routes.callback(_request(), code="auth-code", state=state)
|
||||
response = await auth_routes.callback(
|
||||
_request(),
|
||||
code="auth-code",
|
||||
state=state,
|
||||
oauth_state_cookie=state,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["location"], "https://vignette.test/learn")
|
||||
create_session_mock.assert_awaited_once_with(
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
role="learner",
|
||||
cohort_ids=["pilot-a", "hanshin-2026"],
|
||||
external_id="google:learner@hs.ac.kr",
|
||||
)
|
||||
cookie_blob = "\n".join(
|
||||
value.decode("latin1")
|
||||
for name, value in response.raw_headers
|
||||
|
|
@ -428,6 +553,128 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertNotIn("browser-must-not-see-this", cookie_blob)
|
||||
self.assertNotIn(state, auth_routes._oauth_states)
|
||||
|
||||
async def test_google_callback_accepts_signed_state_after_process_restart(self) -> None:
|
||||
with patched_settings(
|
||||
environment="prod",
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
frontend_base_url="https://vignette.test",
|
||||
session_secret="signed-oauth-state-secret",
|
||||
):
|
||||
login_response = await auth_routes.login(_request(), provider="google", next="/learn")
|
||||
|
||||
query = parse_qs(urlsplit(login_response.headers["location"]).query)
|
||||
state = query["state"][0]
|
||||
expected_verifier = auth_routes._oauth_states[state].code_verifier
|
||||
auth_routes._oauth_states.clear()
|
||||
calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, payload: dict[str, Any]) -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
calls.append(("POST", url, kwargs))
|
||||
return FakeResponse(200, {"id_token": "id-token"})
|
||||
|
||||
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
calls.append(("GET", url, kwargs))
|
||||
return FakeResponse(
|
||||
200,
|
||||
{
|
||||
"aud": "google-client",
|
||||
"iss": "https://accounts.google.com",
|
||||
"email": "learner@hs.ac.kr",
|
||||
"email_verified": "true",
|
||||
"name": "Learner",
|
||||
"hd": "hs.ac.kr",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patched_settings(
|
||||
environment="prod",
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
frontend_base_url="https://vignette.test",
|
||||
session_secret="signed-oauth-state-secret",
|
||||
),
|
||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||
patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))),
|
||||
):
|
||||
response = await auth_routes.callback(
|
||||
_request(),
|
||||
code="auth-code",
|
||||
state=state,
|
||||
oauth_state_cookie=state,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["location"], "https://vignette.test/learn")
|
||||
self.assertEqual(calls[0][2]["data"]["code_verifier"], expected_verifier)
|
||||
|
||||
async def test_google_callback_requires_state_cookie_match(self) -> None:
|
||||
with patched_settings(
|
||||
environment="prod",
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
frontend_base_url="https://vignette.test",
|
||||
session_secret="signed-oauth-state-secret",
|
||||
):
|
||||
login_response = await auth_routes.login(_request(), provider="google", next="/learn")
|
||||
|
||||
state = parse_qs(urlsplit(login_response.headers["location"]).query)["state"][0]
|
||||
auth_routes._oauth_states.clear()
|
||||
|
||||
with patched_settings(
|
||||
environment="prod",
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
frontend_base_url="https://vignette.test",
|
||||
session_secret="signed-oauth-state-secret",
|
||||
):
|
||||
response = await auth_routes.callback(_request(), code="auth-code", state=state)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("oauth=invalid_state", response.headers["location"])
|
||||
|
||||
async def test_google_callback_maps_provider_error_reason(self) -> None:
|
||||
with patched_settings(
|
||||
environment="prod",
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
oauth_redirect_uri="https://api-vignette.test/auth/callback",
|
||||
frontend_base_url="https://vignette.test",
|
||||
session_secret="signed-oauth-state-secret",
|
||||
):
|
||||
response = await auth_routes.callback(
|
||||
_request(),
|
||||
state="provider-state",
|
||||
error="access_denied",
|
||||
error_description="The user denied access.",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["location"], "https://vignette.test/login?oauth=access_denied")
|
||||
|
||||
def test_session_cookie_is_host_prefixed_httponly_secure_lax_without_domain(self) -> None:
|
||||
response = Response()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue