Example #1
0
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
Example #2
0
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)
Example #3
0
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
Example #4
0
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,
    }]