async def test_redeem_code(tmp_path: Path, factory: ComponentFactory) -> None: clients = [ OIDCClient(client_id="client-1", client_secret="client-1-secret"), OIDCClient(client_id="client-2", client_secret="client-2-secret"), ] config = await configure(tmp_path, "github", oidc_clients=clients) factory.reconfigure(config) oidc_service = factory.create_oidc_service() token_data = await create_session_token(factory) token = token_data.token redirect_uri = "https://example.com/" code = await oidc_service.issue_code("client-2", redirect_uri, token) oidc_token = await oidc_service.redeem_code("client-2", "client-2-secret", redirect_uri, code) assert oidc_token.claims == { "aud": config.issuer.aud, "iat": ANY, "exp": ANY, "iss": config.issuer.iss, "jti": 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, } redis = await redis_dependency() assert not await redis.get(f"oidc:{code.key}")
async def test_redeem_code_errors(tmp_path: Path, factory: ComponentFactory) -> None: clients = [ OIDCClient(client_id="client-1", client_secret="client-1-secret"), OIDCClient(client_id="client-2", client_secret="client-2-secret"), ] config = await configure(tmp_path, "github", oidc_clients=clients) factory.reconfigure(config) oidc_service = factory.create_oidc_service() token_data = await create_session_token(factory) token = token_data.token redirect_uri = "https://example.com/" code = await oidc_service.issue_code("client-2", redirect_uri, token) with pytest.raises(InvalidClientError): await oidc_service.redeem_code("some-client", "some-secret", redirect_uri, code) with pytest.raises(InvalidClientError): await oidc_service.redeem_code("client-2", "some-secret", redirect_uri, code) with pytest.raises(InvalidGrantError): await oidc_service.redeem_code( "client-2", "client-2-secret", redirect_uri, OIDCAuthorizationCode(), ) with pytest.raises(InvalidGrantError): await oidc_service.redeem_code("client-1", "client-1-secret", redirect_uri, code) with pytest.raises(InvalidGrantError): await oidc_service.redeem_code("client-2", "client-2-secret", "https://foo.example.com/", code)
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_not_configured( tmp_path: Path, client: AsyncClient, factory: ComponentFactory, caplog: LogCaptureFixture, ) -> None: config = await configure(tmp_path, "oidc") factory.reconfigure(config) token_data = await create_session_token(factory) caplog.clear() r = await client.get( "/auth/tokens/influxdb/new", headers={"Authorization": f"bearer {token_data.token}"}, ) assert r.status_code == 404 assert r.json()["detail"]["type"] == "not_supported" assert parse_log(caplog) == [{ "error": "No InfluxDB issuer configuration", "event": "Not configured", "httpRequest": { "requestMethod": "GET", "requestUrl": (f"https://{TEST_HOSTNAME}/auth/tokens/influxdb/new"), "remoteIp": "127.0.0.1", }, "scope": "user:token", "severity": "warning", "token": token_data.token.key, "token_source": "bearer", "user": token_data.username, }]
async def test_issue_token(tmp_path: Path, factory: ComponentFactory) -> None: config = await configure(tmp_path, "oidc") factory.reconfigure(config) issuer = factory.create_token_issuer() token_data = await create_session_token(factory) oidc_token = issuer.issue_token(token_data, jti="new-jti", scope="openid") assert oidc_token.claims == { "aud": config.issuer.aud, "exp": ANY, "iat": ANY, "iss": config.issuer.iss, "jti": "new-jti", "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() assert now - 5 <= oidc_token.claims["iat"] <= now + 5 expected_exp = now + config.issuer.exp_minutes * 60 assert expected_exp - 5 <= oidc_token.claims["exp"] <= expected_exp + 5
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_influxdb_force_username( tmp_path: Path, client: AsyncClient, factory: ComponentFactory, caplog: LogCaptureFixture, ) -> None: config = await configure(tmp_path, "influxdb-username") factory.reconfigure(config) token_data = await create_session_token(factory) assert token_data.expires influxdb_secret = config.issuer.influxdb_secret assert influxdb_secret caplog.clear() r = await client.get( "/auth/tokens/influxdb/new", headers={"Authorization": f"bearer {token_data.token}"}, ) assert r.status_code == 200 data = r.json() claims = jwt.decode(data["token"], influxdb_secret, algorithms=["HS256"]) assert claims == { "username": "******", "exp": int(token_data.expires.timestamp()), "iat": ANY, } assert parse_log(caplog) == [{ "event": "Issued InfluxDB token", "influxdb_username": "******", "httpRequest": { "requestMethod": "GET", "requestUrl": (f"https://{TEST_HOSTNAME}/auth/tokens/influxdb/new"), "remoteIp": "127.0.0.1", }, "scope": "user:token", "severity": "info", "token": token_data.token.key, "token_source": "bearer", "user": token_data.username, }]
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_issue_code(tmp_path: Path, factory: ComponentFactory) -> None: clients = [OIDCClient(client_id="some-id", client_secret="some-secret")] config = await configure(tmp_path, "github", oidc_clients=clients) factory.reconfigure(config) oidc_service = factory.create_oidc_service() token_data = await create_session_token(factory) token = token_data.token redirect_uri = "https://example.com/" assert config.oidc_server assert list(config.oidc_server.clients) == clients with pytest.raises(UnauthorizedClientException): await oidc_service.issue_code("unknown-client", redirect_uri, token) code = await oidc_service.issue_code("some-id", redirect_uri, token) redis = await redis_dependency() encrypted_code = await redis.get(f"oidc:{code.key}") assert encrypted_code fernet = Fernet(config.session_secret.encode()) serialized_code = json.loads(fernet.decrypt(encrypted_code)) assert serialized_code == { "code": { "key": code.key, "secret": code.secret, }, "client_id": "some-id", "redirect_uri": redirect_uri, "token": { "key": token.key, "secret": token.secret, }, "created_at": ANY, } now = time.time() assert now - 2 < serialized_code["created_at"] < now
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, }]
async def test_token_errors( tmp_path: Path, client: AsyncClient, factory: ComponentFactory, caplog: LogCaptureFixture, ) -> None: clients = [ OIDCClient(client_id="some-id", client_secret="some-secret"), OIDCClient(client_id="other-id", client_secret="other-secret"), ] config = await configure(tmp_path, "github", oidc_clients=clients) factory.reconfigure(config) token_data = await create_session_token(factory) token = token_data.token oidc_service = factory.create_oidc_service() redirect_uri = f"https://{TEST_HOSTNAME}/app" code = await oidc_service.issue_code("some-id", redirect_uri, token) # Missing parameters. request: Dict[str, str] = {} caplog.clear() r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_request", "error_description": "Invalid token request", } assert parse_log(caplog) == [{ "error": "Invalid token request", "event": "Invalid request", "httpRequest": { "requestMethod": "POST", "requestUrl": f"https://{TEST_HOSTNAME}/auth/openid/token", "remoteIp": "127.0.0.1", }, "severity": "warning", }] # Invalid grant type. request = { "grant_type": "bogus", "client_id": "other-client", "code": "nonsense", "redirect_uri": f"https://{TEST_HOSTNAME}/", } caplog.clear() r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "unsupported_grant_type", "error_description": "Invalid grant type bogus", } assert parse_log(caplog) == [{ "error": "Invalid grant type bogus", "event": "Unsupported grant type", "httpRequest": { "requestMethod": "POST", "requestUrl": f"https://{TEST_HOSTNAME}/auth/openid/token", "remoteIp": "127.0.0.1", }, "severity": "warning", }] # Invalid code. request["grant_type"] = "authorization_code" r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_grant", "error_description": "Invalid authorization code", } # No client_secret. request["code"] = str(OIDCAuthorizationCode()) caplog.clear() r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_client", "error_description": "No client_secret provided", } assert parse_log(caplog) == [{ "error": "No client_secret provided", "event": "Unauthorized client", "httpRequest": { "requestMethod": "POST", "requestUrl": f"https://{TEST_HOSTNAME}/auth/openid/token", "remoteIp": "127.0.0.1", }, "severity": "warning", }] # Incorrect client_id. request["client_secret"] = "other-secret" r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_client", "error_description": "Unknown client ID other-client", } # Incorrect client_secret. request["client_id"] = "some-id" r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_client", "error_description": "Invalid secret for some-id", } # No stored data. request["client_secret"] = "some-secret" bogus_code = OIDCAuthorizationCode() request["code"] = str(bogus_code) caplog.clear() r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_grant", "error_description": "Invalid authorization code", } log = json.loads(caplog.record_tuples[0][2]) assert log["event"] == "Invalid authorization code" assert log["error"] == f"Unknown authorization code {bogus_code.key}" # Corrupt stored data. redis = await redis_dependency() await redis.set(bogus_code.key, "XXXXXXX") r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_grant", "error_description": "Invalid authorization code", } # Correct code, but invalid client_id for that code. bogus_code = await oidc_service.issue_code("other-id", redirect_uri, token) request["code"] = str(bogus_code) r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_grant", "error_description": "Invalid authorization code", } # Correct code and client_id but invalid redirect_uri. request["code"] = str(code) r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_grant", "error_description": "Invalid authorization code", } # Delete the underlying token. token_service = factory.create_token_service() async with factory.session.begin(): await token_service.delete_token(token.key, token_data, token_data.username, ip_address="127.0.0.1") request["redirect_uri"] = redirect_uri r = await client.post("/auth/openid/token", data=request) assert r.status_code == 400 assert r.json() == { "error": "invalid_grant", "error_description": "Invalid authorization code", }
async def test_login_errors( 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) # No parameters at all. r = await client.get("/auth/openid/login") assert r.status_code == 422 # Good client ID but missing redirect_uri. login_params = {"client_id": "some-id"} r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 422 # Bad client ID. caplog.clear() login_params = { "client_id": "bad-client", "redirect_uri": f"https://{TEST_HOSTNAME}/", } r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 400 data = r.json() assert data["detail"][0]["type"] == "invalid_client" assert "Unknown client_id bad-client" in data["detail"][0]["msg"] assert parse_log(caplog) == [{ "error": "Unknown client_id bad-client in OpenID Connect request", "event": "Invalid request", "httpRequest": { "requestMethod": "GET", "requestUrl": ANY, "remoteIp": "127.0.0.1", }, "return_url": f"https://{TEST_HOSTNAME}/", "scope": "user:token", "severity": "warning", "token": ANY, "token_source": "cookie", "user": token_data.username, }] # Bad redirect_uri. login_params["client_id"] = "some-id" login_params["redirect_uri"] = "https://foo.example.com/" r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 422 assert "URL is not at" in r.text # Valid redirect_uri but missing response_type. login_params["redirect_uri"] = f"https://{TEST_HOSTNAME}/app" caplog.clear() r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 307 url = urlparse(r.headers["Location"]) assert url.scheme == "https" assert url.netloc == TEST_HOSTNAME assert url.path == "/app" assert url.query query = parse_qs(url.query) assert query == { "error": ["invalid_request"], "error_description": ["Missing response_type parameter"], } assert parse_log(caplog) == [{ "error": "Missing response_type parameter", "event": "Invalid request", "httpRequest": { "requestMethod": "GET", "requestUrl": ANY, "remoteIp": "127.0.0.1", }, "return_url": login_params["redirect_uri"], "scope": "user:token", "severity": "warning", "token": ANY, "token_source": "cookie", "user": token_data.username, }] # Invalid response_type. login_params["response_type"] = "bogus" r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 307 assert query_from_url(r.headers["Location"]) == { "error": ["invalid_request"], "error_description": ["code is the only supported response_type"], } # Valid response_type but missing scope. login_params["response_type"] = "code" r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 307 assert query_from_url(r.headers["Location"]) == { "error": ["invalid_request"], "error_description": ["Missing scope parameter"], } # Invalid scope. login_params["scope"] = "user:email" r = await client.get("/auth/openid/login", params=login_params) assert r.status_code == 307 assert query_from_url(r.headers["Location"]) == { "error": ["invalid_request"], "error_description": ["openid is the only supported scope"], }