async def test_verify_oidc(tmp_path: Path, respx_mock: respx.Router, factory: ComponentFactory) -> None: config = await configure(tmp_path, "oidc") factory.reconfigure(config) verifier = factory.create_token_verifier() now = datetime.now(timezone.utc) exp = now + timedelta(days=24) payload: Dict[str, Any] = { "aud": config.verifier.oidc_aud, "iat": int(now.timestamp()), "exp": int(exp.timestamp()), } keypair = config.issuer.keypair token = encode_token(payload, keypair) excinfo: ExceptionInfo[Exception] # Missing iss. with pytest.raises(InvalidIssuerError) as excinfo: await verifier.verify_oidc_token(token) assert str(excinfo.value) == "No iss claim in token" # Missing kid. payload["iss"] = "https://bogus.example.com/" token = encode_token(payload, keypair) with pytest.raises(UnknownKeyIdException) as excinfo: await verifier.verify_oidc_token(token) assert str(excinfo.value) == "No kid in token header" # Unknown issuer. token = encode_token(payload, keypair, kid="a-kid") with pytest.raises(InvalidIssuerError) as excinfo: await verifier.verify_oidc_token(token) assert str(excinfo.value) == "Unknown issuer: https://bogus.example.com/" # Unknown kid. payload["iss"] = config.verifier.oidc_iss token = encode_token(payload, keypair, kid="a-kid") with pytest.raises(UnknownKeyIdException) as excinfo: await verifier.verify_oidc_token(token) expected = f"kid a-kid not allowed for {config.verifier.oidc_iss}" assert str(excinfo.value) == expected # Missing username claim. await mock_oidc_provider_config(respx_mock, keypair) kid = config.verifier.oidc_kids[0] token = encode_token(payload, config.issuer.keypair, kid=kid) with pytest.raises(MissingClaimsException) as excinfo: await verifier.verify_oidc_token(token) expected = f"No {config.verifier.username_claim} claim in token" assert str(excinfo.value) == expected # Missing UID claim. await mock_oidc_provider_config(respx_mock, keypair) payload[config.verifier.username_claim] = "some-user" token = encode_token(payload, config.issuer.keypair, kid=kid) with pytest.raises(MissingClaimsException) as excinfo: await verifier.verify_oidc_token(token) expected = f"No {config.verifier.uid_claim} claim in token" assert str(excinfo.value) == expected
async def test_key_retrieval(tmp_path: Path, respx_mock: respx.Router, factory: ComponentFactory) -> None: config = await configure(tmp_path, "oidc-no-kids") factory.reconfigure(config) assert config.oidc verifier = factory.create_token_verifier() # Initial working JWKS configuration. jwks = config.issuer.keypair.public_key_as_jwks("some-kid") # Register that handler at the well-known JWKS endpoint. This will return # a connection refused from the OpenID Connect endpoint. jwks_url = urljoin(config.oidc.issuer, "/.well-known/jwks.json") oidc_url = urljoin(config.oidc.issuer, "/.well-known/openid-configuration") respx_mock.get(jwks_url).respond(json=jwks.dict()) respx_mock.get(oidc_url).respond(404) # Check token verification with this configuration. token = await create_upstream_oidc_token(kid="some-kid") assert await verifier.verify_oidc_token(token) # Wrong algorithm for the key. jwks.keys[0].alg = "ES256" respx_mock.get(jwks_url).respond(json=jwks.dict()) with pytest.raises(UnknownAlgorithmException): await verifier.verify_oidc_token(token) # Should go back to working if we fix the algorithm and add more keys. # Add an explicit 404 from the OpenID connect endpoint. respx_mock.get(oidc_url).respond(404) jwks.keys[0].alg = ALGORITHM keypair = RSAKeyPair.generate() jwks.keys.insert(0, keypair.public_key_as_jwks("a-kid").keys[0]) respx_mock.get(jwks_url).respond(json=jwks.dict()) assert await verifier.verify_oidc_token(token) # Try with a new key ID and return a malformed reponse. respx_mock.get(jwks_url).respond(json=["foo"]) token = await create_upstream_oidc_token(kid="malformed") with pytest.raises(FetchKeysException): await verifier.verify_oidc_token(token) # Return a 404 error. respx_mock.get(jwks_url).respond(404) with pytest.raises(FetchKeysException): await verifier.verify_oidc_token(token) # Fix the JWKS handler but register a malformed URL as the OpenID Connect # configuration endpoint, which should be checked first. jwks.keys[1].kid = "another-kid" respx_mock.get(jwks_url).respond(json=jwks.dict()) respx_mock.get(oidc_url).respond(json=["foo"]) token = await create_upstream_oidc_token(kid="another-kid") with pytest.raises(FetchKeysException): await verifier.verify_oidc_token(token) # Try again with a working OpenID Connect configuration. respx_mock.get(oidc_url).respond(json={"jwks_uri": jwks_url}) assert await verifier.verify_oidc_token(token)
async def test_verify_oidc_no_kids(tmp_path: Path, respx_mock: respx.Router, factory: ComponentFactory) -> None: config = await configure(tmp_path, "oidc-no-kids") factory.reconfigure(config) keypair = config.issuer.keypair verifier = factory.create_token_verifier() await mock_oidc_provider_config(respx_mock, keypair, "kid") now = datetime.now(timezone.utc) exp = now + timedelta(days=24) payload: Dict[str, Any] = { "aud": config.verifier.oidc_aud, "iat": int(now.timestamp()), "iss": config.verifier.oidc_iss, "exp": int(exp.timestamp()), } token = encode_token(payload, keypair, kid="a-kid") with pytest.raises(UnknownKeyIdException) as excinfo: await verifier.verify_oidc_token(token) expected = f"Issuer {config.verifier.oidc_iss} has no kid a-kid" assert str(excinfo.value) == expected
async def test_login( tmp_path: Path, client: AsyncClient, factory: ComponentFactory, caplog: LogCaptureFixture, ) -> None: clients = [OIDCClient(client_id="some-id", client_secret="some-secret")] config = await configure(tmp_path, "github", oidc_clients=clients) factory.reconfigure(config) token_data = await create_session_token(factory) await set_session_cookie(client, token_data.token) return_url = f"https://{TEST_HOSTNAME}:4444/foo?a=bar&b=baz" # Log in caplog.clear() r = await client.get( "/auth/openid/login", params={ "response_type": "code", "scope": "openid", "client_id": "some-id", "state": "random-state", "redirect_uri": return_url, }, ) assert r.status_code == 307 url = urlparse(r.headers["Location"]) assert url.scheme == "https" assert url.netloc == f"{TEST_HOSTNAME}:4444" assert url.path == "/foo" assert url.query query = parse_qs(url.query) assert query == { "a": ["bar"], "b": ["baz"], "code": [ANY], "state": ["random-state"], } code = query["code"][0] assert parse_log(caplog) == [{ "event": "Returned OpenID Connect authorization code", "httpRequest": { "requestMethod": "GET", "requestUrl": ANY, "remoteIp": "127.0.0.1", }, "return_url": return_url, "scope": "user:token", "severity": "info", "token": token_data.token.key, "token_source": "cookie", "user": token_data.username, }] # Redeem the code for a token and check the result. caplog.clear() r = await client.post( "/auth/openid/token", data={ "grant_type": "authorization_code", "client_id": "some-id", "client_secret": "some-secret", "code": code, "redirect_uri": return_url, }, ) assert r.status_code == 200 assert r.headers["Cache-Control"] == "no-store" assert r.headers["Pragma"] == "no-cache" data = r.json() assert data == { "access_token": ANY, "token_type": "Bearer", "expires_in": ANY, "id_token": ANY, } assert isinstance(data["expires_in"], int) exp_seconds = config.issuer.exp_minutes * 60 assert exp_seconds - 5 <= data["expires_in"] <= exp_seconds assert data["access_token"] == data["id_token"] verifier = factory.create_token_verifier() token = verifier.verify_internal_token(OIDCToken(encoded=data["id_token"])) assert token.claims == { "aud": config.issuer.aud, "exp": ANY, "iat": ANY, "iss": config.issuer.iss, "jti": OIDCAuthorizationCode.from_str(code).key, "name": token_data.name, "preferred_username": token_data.username, "scope": "openid", "sub": token_data.username, config.issuer.username_claim: token_data.username, config.issuer.uid_claim: token_data.uid, } now = time.time() expected_exp = now + config.issuer.exp_minutes * 60 assert expected_exp - 5 <= token.claims["exp"] <= expected_exp assert now - 5 <= token.claims["iat"] <= now username = token_data.username assert parse_log(caplog) == [{ "event": f"Retrieved token for user {username} via OpenID Connect", "httpRequest": { "requestMethod": "POST", "requestUrl": f"https://{TEST_HOSTNAME}/auth/openid/token", "remoteIp": "127.0.0.1", }, "severity": "info", "token": OIDCAuthorizationCode.from_str(code).key, "user": username, }]